Most teams do not decide how to lay out Terraform state. They start with one root module because that is the fastest way to get something running, and then the business grows around it. Two years later a single terraform plan takes eleven minutes, touches four hundred resources, and nobody wants to run apply without another engineer watching.
That is not a tooling failure. State layout is a design decision, and it is one of the few Terraform decisions that gets materially harder to change later.
What goes wrong in one big state file
A monolithic state accumulates specific, predictable problems.
- Blast radius. Every apply can touch everything. A change to a security group sits in the same plan as your production database. Reviewers cannot distinguish routine changes from dangerous ones, so they stop reading carefully.
- Plan latency. Refresh time scales with resource count. Once plans take long enough to interrupt a train of thought, engineers batch changes — which makes each apply larger and riskier, which makes people batch more.
- Lock contention. One state file means one lock. Two engineers cannot work on unrelated infrastructure at the same time.
- Coupled failure. A provider error while creating one resource can leave the whole apply half-finished, mixing unrelated changes into the same partially-applied state.
Split along boundaries that already exist
The useful principle: state boundaries should follow the boundaries where change actually happens, and where failure should stop.
In practice that usually means separating by lifecycle rather than by resource type:
- Foundation — accounts, VPCs, subnets, transit connectivity, DNS zones. Changes rarely, and a mistake is expensive.
- Shared platform — clusters, shared data stores, registries, base IAM. Changes on a monthly cadence.
- Per-service or per-environment — the resources a single service owns. Changes constantly.
The test is a question: when this changes, what else should be allowed to change with it? Resources that answer differently belong in different states.
Splitting by resource type — all IAM in one state, all networking in another — reads tidily and works badly, because a single logical change then spans several states in a required order.
Pass data between states deliberately
Once you have multiple states, they need to reference each other. The mechanism matters less than the direction.
Prefer looking values up from the authoritative source — data sources against tags or names, or values published to a parameter store — over chaining terraform_remote_state through several layers. Remote state reads create a dependency graph that is invisible in code review and painful to unwind, and they couple consumers to the internal structure of another state rather than to a stable contract.
Whatever you choose, make it one-directional. Foundation publishes; services consume. Never the reverse.
Drift is a process problem
Every long-lived AWS account has drift: something changed in the console during an incident and was never brought back into code. Drift itself is not the failure. Not knowing about it is.
Two habits fix most of it:
- Detect continuously. Run
planon a schedule against every state and alert on unexpected diffs. A plan that is empty except when someone is actively changing something is a strong signal, and it is cheap to maintain. - Reconcile immediately. Drift found today is a five-minute fix. Drift found in six months is an archaeology project, because nobody remembers whether the console change was deliberate.
The emergency console change is fine, incidentally. Fixing production by hand at 2am is sometimes the correct call. What matters is that it comes back into code the next working day, while the reason is still fresh.
Modules are for repetition, not for abstraction
Module design goes wrong in the same way in most repositories: a module is written to abstract a concept rather than to remove repetition, gathers configuration options as edge cases arrive, and eventually every input is threaded through as a variable. At that point the module is a slower, less readable version of the resources it wraps.
A narrower rule works better. Write a module when you have the same composition of resources in three or more places and want them to stay identical. Keep the interface small. If a caller needs an option the module does not expose, consider whether that caller should be using the resources directly instead.
What to do with an existing monolith
You do not need to stop and rewrite. Incremental extraction works, and it is safer:
- Pick the newest boundary first — something recently added and loosely coupled. It builds the pattern with the least risk.
- Move resources with
terraform state mvor import blocks, and verify with a plan that shows no changes. A clean plan on both sides is the whole test. - Establish the data-passing pattern once, on that first extraction, and reuse it.
- Extract the highest-risk foundation resources last, when the pattern is proven and the team trusts it.
Each step leaves you in a working state, which means you can stop between steps and nothing is broken. That property is worth more than speed.
The signal that this matters now
You do not need to design perfect state layout on day one. You do need to notice when the current layout has started shaping behaviour: when engineers batch changes, when plans get skimmed instead of read, when applies happen with an audience. Those are the symptoms of state layout that outgrew its design — and they get more expensive to fix every quarter you leave them.