Root Modules vs Shared Modules
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.
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.
# 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.
- Putting a
backendor hardcodedproviderblock 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
providerinside a child module rather than passing it from the root, which is deprecated and fights provider inheritance.
- 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
refso 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.
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
reflets 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