Infrastructure repositories fail in a predictable way. The first environment is clean. The second gets a dev and prod directory with a fair amount of copy-paste. By the fourth, a change to a security group means editing six files, terraform plan takes eleven minutes, and one engineer has become the only person who will run apply.
The cause is not discipline. It is that the layout optimised for the two-environment case, and the two-environment case is the one you spend the least time in.
Organise by blast radius
The most useful question when deciding what belongs in a state file is not "what is related?" It is "what am I willing to destroy together?"
Things that change on wildly different schedules should never share state. A VPC changes twice a year. A Kubernetes node group changes twice a month. An application's IAM role changes twice a week. Putting all three in one state means every application change requires a plan that could, if something goes wrong, propose deleting your network.
Four tiers, each with independent state:
- 00-foundation
- Accounts, organisations, DNS zones, state backends. Changes yearly.
- 10-network
- VPCs, subnets, peering, transit gateways, firewall baselines. Changes quarterly.
- 20-platform
- Clusters, node groups, databases, shared ingress. Changes monthly.
- 30-workload
- Per-service IAM, buckets, queues, DNS records. Changes daily.
The numeric prefix is not decoration. It is the dependency order, it is obvious to someone who has never seen the repository, and it makes the bootstrap sequence self-documenting.
infrastructure/
├── modules/ # reusable, versioned, no environment knowledge
│ ├── network/
│ ├── eks-cluster/
│ ├── node-group/
│ └── service-account/
├── stacks/ # composed, environment-aware
│ ├── 00-foundation/
│ ├── 10-network/
│ │ ├── main.tf
│ │ └── envs/
│ │ ├── prod-eu.tfvars
│ │ ├── prod-us.tfvars
│ │ └── staging-eu.tfvars
│ ├── 20-platform/
│ └── 30-workload/
└── policies/ # OPA / Conftest rules applied to every planEnvironments are variable files, not directories
This is the change that makes the fourth environment cheap.
A directory per environment guarantees drift. The directories start identical, someone fixes a bug in prod under pressure, and six weeks later staging and production differ in ways nobody has written down — which means staging has stopped being a test of anything.
One stack, many variable files:
cd stacks/20-platform
terraform init -backend-config=envs/prod-eu.backend
terraform plan -var-file=envs/prod-eu.tfvarsNow a bug fix is one edit in one place, applied to every environment through the same promotion path. And the diff between two environments is a diff between two small files — readable in one screen, reviewable in a pull request.
Modules should be boring and versioned
A good Terraform module has three properties, and most in-house modules have none of them.
It knows nothing about environments. No count = var.environment == "prod" ? 3 : 1. The caller decides; the module implements. A module containing environment names is a stack wearing a costume.
It is versioned independently. Reference modules by tag, not by relative path. Relative paths mean every consumer upgrades simultaneously, whether they are ready or not.
module "cluster" {
source = "git::ssh://git@github.com/example/infrastructure.git//modules/eks-cluster?ref=eks-cluster/v2.4.0"
name = var.cluster_name
kubernetes_version = var.kubernetes_version
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
node_groups = var.node_groups
tags = local.tags
}Its interface is smaller than its implementation. If a module takes 40 variables, it is not an abstraction — it is a pass-through with extra steps. Ten well-chosen variables with sensible defaults beat forty that expose every underlying resource argument.
Cross-stack references
Stacks need each other's outputs. There are two ways to do this and the difference matters.
# Explicit dependency on another stack's published outputs.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = var.state_bucket
key = "10-network/${var.environment}/terraform.tfstate"
region = var.state_region
}
}terraform_remote_state couples you to another stack's state file, which means its outputs are now a public API you cannot change freely. That is acceptable downward — platform depending on network is a stable direction. It is not acceptable sideways or upward.
For anything crossing a team boundary, use data sources that query the provider by tag instead. Slower to plan, but it couples you to the cloud's own API rather than to another team's state layout:
data "aws_vpc" "main" {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Tier = "network"
}
}This is also why a tagging contract is load-bearing rather than cosmetic. Tags are the join key between stacks, the input to cost allocation, and the selector for half your automation. Define them once in a locals block and apply them with default_tags at the provider.
Policy in the pipeline, not in review
Human review does not reliably catch an unencrypted bucket or an over-permissive security group at eleven o'clock at night. Automated policy does, every time, without a conversation.
package terraform.encryption
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not encryption_configured(resource)
msg := sprintf("S3 bucket '%s' has no server-side encryption", [resource.address])
}
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.storage_encrypted != true
msg := sprintf("RDS instance '%s' is not encrypted at rest", [resource.address])
}Run it against the plan JSON in CI, before anyone is asked to approve:
terraform plan -out=tfplan -var-file="envs/${ENV}.tfvars"
terraform show -json tfplan > tfplan.json
conftest test --policy policies/ tfplan.jsonThe point is not that policy is smarter than your reviewers. It is that policy is consistent, and it moves the conversation in review from "did you encrypt this" to "is this the right architecture" — which is the only part a human is actually good at.
Drift is a metric, not an incident
Something will change your infrastructure outside Terraform. An emergency console change during an outage, an AWS-managed update, another team's automation. Pretending otherwise means discovering the drift during an unrelated apply, at the worst time.
Run a plan on a schedule and alert on non-empty diffs:
for stack in stacks/*/; do
terraform -chdir="$stack" plan -detailed-exitcode -var-file="envs/${ENV}.tfvars"
case $? in
0) echo "$stack: clean" ;;
2) echo "$stack: DRIFT"; report_drift "$stack" ;;
*) echo "$stack: plan failed"; exit 1 ;;
esac
done-detailed-exitcode is the flag that makes this possible: 0 for no changes, 2 for changes present, anything else for a failure. Track the count of drifted stacks over time. A rising number is a signal about your process — usually that the paved path is too slow and people are routing around it — long before it is a signal about any individual resource.
What good looks like
You will know the layout is working when a new region is a new .tfvars file and an afternoon, when a junior engineer can run plan without supervision because policy catches the dangerous things, and when the blast radius of any single apply is small enough that nobody's stomach tightens.
None of that requires a sophisticated tool. It requires deciding, early, what you are willing to destroy together — and then never letting two things with different answers share a state file.