Writing
Infrastructure as code in practice: structuring Terraform for a team that is growing
The layout that survives a second environment and a third engineer — what belongs in a module and what does not, why state boundaries matter more than folder structure, and the review discipline that stops a plan being rubber-stamped.
6 min read
Terraform problems are almost never Terraform problems. They are boundary problems: one state file that everything writes to, a module that knows too much about its caller, an environment that drifted because somebody fixed production by hand at 2am.
This is the structure we use and the reasoning for each boundary.
State boundaries decide everything else
Before folders, before modules: decide what shares a state file. That single choice determines your blast radius, your plan times and who can break what.
Split along two axes — by environment, and by rate of change:
state/
prod/network rarely changes, everything depends on it
prod/data databases, buckets — changes carefully
prod/platform cluster, load balancers
prod/services/api changes several times a week
staging/... same shapeCross-boundary references go through data sources or explicit outputs, never by reaching into another layer's state:
data "aws_vpc" "main" {
tags = { Name = "prod-main" }
}That indirection is the point. The service layer knows a VPC with that tag exists; it does not know or care how the network layer built it, and cannot accidentally modify it.
A module is an interface, not a folder
The most common mistake is turning every group of resources into a module. A module earns its place when it hides a decision — otherwise it is indirection with a variables file.
| Write a module when | Do not when |
|---|---|
| The same shape is used 3+ times | It is used once |
| It encodes a decision worth reusing | Every input is passed straight through |
| Its interface is smaller than its contents | Its variables outnumber its resources |
| A caller can use it without reading it | Using it requires reading it |
A good module has a narrow, opinionated interface:
module "service" {
source = "../../modules/ecs-service"
name = "api"
image = var.image # the immutable tag from CI
cpu = 512
memory = 1024
desired_count = 3
# The module decides: log group, retention, task role, target group,
# health check, deployment circuit breaker, alarms.
}Six inputs, and it produces perhaps thirty resources with the conventions already applied. Compare that to a module taking forty variables, which is the same resources with an extra file in front of them.
Version modules, and pin the version at the call site
A module in a separate repository at
?ref=v2.3.0. Otherwise a change to a shared module changes every environment at once, and you find out during the next apply.Never put a provider block inside a module
It makes the module impossible to use across regions or accounts, and removing it later is a breaking change for every caller.
Output what callers need, not everything
Every output is part of the interface. A module returning its whole resource set has no interface.
Environments differ by variables, not by copies
The failure that produces drift is copy-pasting an environment folder and editing it. Six months later staging and production differ in ways nobody can enumerate — which means staging has stopped testing anything.
environments/
prod/main.tf ← identical composition
prod/prod.tfvars ← the differences, in one readable file
staging/main.tf ← identical composition
staging/staging.tfvarsMaking the plan reviewable
A plan output nobody reads is a review that is not happening. Three things make it readable.
Post the plan on the pull request, and require an apply after merge
Plan on PR, apply on merge to main. The change is reviewed as a diff of proposed infrastructure rather than as a diff of HCL.
Make destructive changes impossible to miss
Grep the plan for replacements and deletions and label the PR.
must be replacedon a database is the line that matters, and it is easy to miss in four hundred lines of output.Keep plans small by keeping states small
A plan touching six resources is read. A plan touching two hundred is scrolled past. This is the practical payoff of the state boundaries above.
# For the resources where a replacement is never acceptable.
resource "aws_db_instance" "main" {
# …
lifecycle {
prevent_destroy = true
}
}Drift is a process problem
Someone will change something in the console during an incident. That is not a discipline failure — it is what you want them to do when the site is down. The failure is not reconciling it afterwards.
Run a scheduled plan and alert on any diff
Nightly, against every environment. A non-empty plan on a day nobody deployed is drift, and you want to hear about it the next morning rather than the next quarter.
Import rather than recreate
Whatever was made by hand,
importit into state. Deleting and re-creating is how a console fix becomes an outage.Treat repeated drift in one place as a design signal
If people keep changing the same thing by hand, it probably should not be in Terraform — or it needs a safer path.
Secrets never go in state
Terraform state contains resource attributes in plaintext, including generated passwords. That is not a bug you can configure away.
Encrypt the state backend and restrict access to it
State is a credential store whether or not you intended it to be.
Create the secret container, not the secret
Terraform makes the Secrets Manager entry; a separate process populates the value. The application reads it at runtime.
Reference secrets by ARN in task definitions
So the value is resolved by the runtime and never passes through a plan.
What to enforce in CI
fmt and validate on every commit
Formatting arguments in review are wasted review.
A static analyser for security misconfiguration
Public buckets, open security groups, unencrypted volumes — the findings are usually real and usually one line to fix.
A policy check for the rules you actually care about
Every resource tagged with an owner and an environment; no
0.0.0.0/0on anything but a load balancer. Encode the rules rather than repeating them in review.Cost estimation on the plan
A PR that adds an instance type nobody priced is worth catching before the invoice.
The shape, summarised
Split state by environment and rate of change, before anything else
It determines blast radius, plan time and how many people can work at once.
Write a module only when it hides a decision
Narrow interface, versioned, pinned at the call site, no provider block inside.
One composition per environment; differences live in tfvars
Divergent main.tf files are how staging quietly stops testing production.
Plan on the PR, apply on merge, flag replacements loudly
And keep plans small enough that somebody reads them.
Detect drift nightly, import rather than recreate
A console fix during an incident is correct; not reconciling it is not.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.