Skip to content

Terraform State Boundaries

Why a Terraform project divides into accounts and root modules, and what each division costs

Related Concepts: Coupling and Cohesion | Module Decomposition | Terraform Module Philosophy | Implements: Terraform Project Structure

The State File as a Boundary

Every root module owns exactly one state file, and that file is the unit of locking, the unit of a plan, and the blast radius of an apply. Deciding how to divide infrastructure into root modules is therefore a decision about where state stops. Independent review and independent failure both follow from that single fact, and so does every cost described below.

Isolation at the Provider's Strongest Boundary

Top-level directories mirror the accounts of an AWS Organization because the account is the strongest isolation the provider offers. Credentials, quotas, and billing all stop at its edge. A mistake made while holding dev credentials cannot reach a prod resource, because those credentials cannot address it. That guarantee holds whatever the configuration says, which makes it stronger than any convention we could enforce inside a single account.

The pattern transfers to the strongest separation any provider offers: resource groups in Azure, projects in GCP, organizations and spaces in Cloud Foundry. The principle is environment isolation at the provider's hardest boundary, with root modules nested inside for finer-grained separation.

What a Cross-Root-Module Dependency Costs

Coupling and cohesion shape infrastructure the way they shape application code. In Terraform the cost of coupling is amplified by the friction of terraform_remote_state, since every dependency of that kind requires explicit backend configuration and stays invisible until someone reads the data blocks. Each one carries three costs:

  • Change amplification. Renaming an output in the producer requires updating every consumer.
  • Temporal coupling. The producer applies first, and that order lives in a pipeline definition or in someone's memory while the configuration stays silent about it.
  • Cognitive load. Understanding one root module requires tracing dependencies into another root module's state.

The dependency is acceptable when the producer is a stable foundation such as a VPC, DNS zones, or shared IAM roles, where the interface changes rarely. It grows expensive when both sides change often, because then every change carries coordination.

Cohesion Within a Root Module

Infrastructure that changes together belongs together. A root module reads well when it represents a unit that deploys as one thing, such as a load balancer with its security groups, access logs, and the WAF attached to it. Separating those into their own root modules couples them through remote state and returns nothing, because they never change independently.

Low cohesion announces itself. Resources sit untouched while everything around them changes, outputs exist only to feed another root module, and applies report no changes almost every time they run.

Deciding Where to Divide

Divide on observed differences in lifecycle:

  • Rate of change. Shared networking changes quarterly while application services change daily.
  • Blast radius. A bad apply to one service stays away from the database and the VPC.
  • Ownership. Separate teams manage separate infrastructure.
  • Deployment schedule. Some infrastructure deploys on merge and some waits for a change window.

Two pieces of infrastructure that feel conceptually distinct while always changing and deploying together belong in the same root module. A conceptual difference on its own is a weak reason to pay for remote state indirection.

Feedback Speed as a Reason to Divide

Every plan refreshes the state of every resource its root module manages, so plan and refresh time grow with the size of the root module. A root module that holds an entire account's infrastructure makes each plan slow, and that cost lands on every change to anything inside it, including a one-line change to a single resource. Root module size and feedback speed are directly linked, which makes a smaller root module with a cross-module dependency a defensible trade.

This force pulls against the cost of coupling described above, and both costs are real. Our preference is root modules that are as independent as possible, because an independent root module carries no coordination cost and can be read and applied on its own. We also accept cross-module dependencies when a smaller root module buys meaningfully faster operations, since a slow plan taxes every contributor on every change and a slow feedback loop is what makes people batch changes together. The balance is a judgment call: watch how long plans actually take, count the dependencies a division introduces, and choose the arrangement that keeps both numbers low.

Convention as a Looser Contract

When a division is justified, the coupling that remains still has a shape worth choosing. A consumer that reads a producer's state backend depends on the bucket, the key layout, and the internal structure of another root module's state. A consumer that discovers infrastructure by tag or naming convention depends on the convention alone, which is more stable and easier to keep compatible as the producer evolves. Treating the outputs of a foundational root module as a public API works for the same reason: the contract stays narrow and deliberate, so consumers depend on a small surface that changes slowly. See Data-Only Modules for how we implement discovery by convention.