Chapter 9: Organizing Larger Codebases
Topic 54

Root Modules vs Shared Modules

ModulesVersioning

A clean larger codebase separates two kinds of module sharply. Shared modules are reusable, parameterized, and have no backend — they are libraries. Root modules are composed, environment-specific, hold a backend, and get applied — they are the applications. A lot of organizational pain comes from blurring the line: putting a backend inside a shared module so it cannot be reused, or duplicating logic across roots that should have lived in a shared module.

The mental model that keeps this straight is the library-versus-application split from software. A library is consumed by many callers and knows nothing about where it runs; an application wires libraries together and owns the deployment details. Shared modules are libraries; root modules are applications.

Two kinds of module
Root module
The application. Holds the backend and provider config, composes shared modules, and is the directory you run apply in.
Shared module
The library. Reusable and parameterized with no backend, consumed by many roots, and pinned to a version with a ref.

Root Modules

A root module is the directory you run terraform apply in. It owns the things that are specific to one deployment: the backend block that says where state lives, the provider configuration with its region and account, and the environment's input values. Its job is composition — calling shared modules and feeding them inputs — not implementing resource logic itself. Each environment from the previous topic is a root module.

Shared Modules

A shared module is reusable and parameterized. It declares resources behind a clean variable interface, exposes outputs, and deliberately holds no backend block and no hardcoded provider configuration — those are the caller's responsibility. That omission is what makes it reusable: the same network module can be called from dev, staging, and prod precisely because it does not assume which state or account it runs in.

A thin root module composing a versioned shared module
# environments/prod/main.tf — a root module: backend + composition
terraform {
  backend "s3" {}            # prod state, filled via -backend-config
}

provider "aws" {
  region = "us-east-1"
}

module "network" {
  source  = "git::https://github.com/acme/tf-modules.git//network?ref=v2.3.0"
  cidr    = "10.20.0.0/16"
  env     = "prod"
}

What Belongs Where

The dividing rule is short: backend and provider configuration live in root modules only; reusable resource patterns live in shared modules. A shared module that declares its own backend pins every consumer to one state and is no longer shared at all. A root module that grows resource logic instead of delegating it duplicates that logic into every other root. Keep deployment concerns in the root and resource patterns in the module, and the boundary holds.

Versioning and the Thin-Root Pattern

Shared modules are pinned the way you pin any dependency: tag a release (v2.3.0) and reference it with a ref in the source, so a root module upgrades on its own schedule rather than tracking an unpinned branch that breaks the next time someone pushes to it. The companion practice is the thin root: keep each root mostly module calls and inputs, pushing logic down into versioned shared modules. A thin root makes every environment a short, readable composition instead of bespoke code, and the difference between environments shrinks to a handful of inputs.

Common Mistakes
  • Putting a backend or hardcoded provider block inside a shared module, pinning every consumer to one state and making the module un-reusable.
  • Letting root modules accumulate duplicated resource logic that should live once in a shared module, so a fix has to be made in every environment.
  • Referencing a shared module by an unpinned branch, so every root silently tracks the latest commit and breaks the next time someone pushes.
  • Making root modules fat with resource logic, so each environment is effectively bespoke code instead of a thin composition.
  • Configuring a provider inside a child module rather than passing it from the root, which is deprecated and fights provider inheritance.
Best Practices
  • Keep backend and provider configuration in root modules only; let shared modules stay backend- and provider-agnostic.
  • Push reusable resource patterns into versioned shared modules and keep root modules thin.
  • Tag and pin shared modules with a ref so each root upgrades deliberately on its own schedule.
  • Let root modules be mostly composition — module calls plus environment inputs — not resource declarations.
  • Treat a shared module's variables and outputs as a published interface, changing them with the care of any breaking API change.
Comparable tools Pulumi component packages versus program stacks mirror the split CloudFormation nested-stack templates versus the parent stack that deploys them Software the library-versus-application distinction is the direct analogy

Knowledge Check

What distinguishes a root module from a shared module?

  • A root module has a backend and is the directory you apply; a shared module is reusable, parameterized, and has no backend of its own
  • A shared module declares the backend and the root module borrows that backend from it at apply time, so the reusable library is the piece that decides where state lives
  • Root modules are not allowed to call any other modules, whereas shared modules can call them freely, so all composition has to happen inside the reusable library layer
  • The two are identical; the names just describe where each module happens to sit in the directory tree, with no difference at all in what each one is responsible for

Why must a shared module not contain a backend block?

  • A backend pins the module to one state, so every consumer would share that state and the module would no longer be reusable
  • Terraform flatly forbids declaring more than one backend block anywhere across a configuration tree, so a module carrying its own backend would make the whole tree fail to initialize
  • A backend declared inside a module doubles the cost of every plan that the module appears in, because Terraform has to evaluate the nested backend a second time on each run
  • Backends can only be declared in a file named main.tf, which shared modules do not have

How does versioning a shared module protect the root modules that consume it?

  • Pinning a ref lets each root upgrade deliberately instead of tracking the latest commit and breaking on an unrelated push
  • A version tag encrypts the module's source so that only authorized roots are able to read it, keeping every other consumer locked out of the underlying code entirely
  • Versioning forces every root to upgrade at the same time, keeping all of them in strict lockstep so no single root can ever drift onto an older release of the module
  • A pinned version makes the module's plan run measurably faster in each root that consumes it, since Terraform can skip resolving the source once the exact ref is fixed

What is the thin-root pattern?

  • Keeping root modules mostly module calls and inputs, pushing resource logic down into versioned shared modules
  • Removing the backend from each root module so that the root takes up noticeably less space and defers entirely to a shared module to declare where its state should live
  • Putting all of an environment's resource logic directly in the root so it is fully visible in one place
  • Splitting each root into many tiny files so that no single file ever grows large, keeping every root lean by spreading its resource blocks thin across the directory

You got correct