Skip to content
Field notes

A Terraform layout that survives the fourth environment

Most infrastructure repositories work for two environments and collapse at the fourth. A module and state layout built around blast radius, plus the composition pattern that makes a new region an afternoon rather than a fortnight.

Platform6 min readArchitecture read

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.

repository layout
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 plan

Environments 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:

terminal
cd stacks/20-platform
 
terraform init -backend-config=envs/prod-eu.backend
terraform plan -var-file=envs/prod-eu.tfvars

Now 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.

stacks/20-platform/main.tf
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.

stacks/20-platform/data.tf
# 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:

stacks/30-workload/data.tf
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.

policies/encryption.rego
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:

.github/workflows/terraform.yml
terraform plan -out=tfplan -var-file="envs/${ENV}.tfvars"
terraform show -json tfplan > tfplan.json
conftest test --policy policies/ tfplan.json

The 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:

nightly drift check
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.

Keep reading

Databases

Provisioning MongoDB with Ops Manager, end to end

A production walkthrough of self-managed MongoDB Enterprise: sizing the Application Database, deploying Ops Manager on Kubernetes, wiring oplog and S3 snapshot stores, and provisioning your first replica set through the operator.

10 min read3–4 hr lab
Kubernetes

Building a Kubernetes cluster you would actually run in production

kubeadm from bare metal to a working control plane: the kernel prerequisites nobody documents, a stacked etcd HA topology, CNI and CSI choices that survive a node loss, and the seven things to fix before you hand it to a team.

8 min read2–3 hr lab
Kubernetes

Writing a Kubernetes operator: from CRD to reconcile loop

A complete operator in Go with Kubebuilder — designing the CRD, writing a reconciler that is genuinely idempotent, handling finalizers and ownership, and the status conventions that make your resource behave like a native one.

11 min read3 hr lab

Next step

Tell us what you are trying to run.

A 30-minute architecture call. We will tell you what we would build, what it costs to operate, and whether you need us at all.