InsightsInfrastructure as Code
Structuring Terraform for many environments
How to lay out modules, state and environment definitions so that adding an environment is a small, reviewable change rather than a copy-paste exercise.
Published October 13, 20263 min read
Most Terraform codebases start the same way: one directory, one state file, one environment. The trouble starts with the second environment. Copying the directory works for a week. After that, every change has to be made twice, the copies drift, and nobody is sure which differences are intentional.
This article describes a layout that scales from two environments to dozens without turning every change into a migration.
Separate what changes from how it is built
The core idea is to split infrastructure code into two kinds of directories:
- Modules describe how something is built: a network, a cluster, a database. They take inputs and have no knowledge of environments.
- Live configurations describe what exists: which modules are used in which environment, with which inputs. They contain almost no logic.
A typical layout looks like this:
infrastructure/
├── modules/
│ ├── network/
│ ├── cluster/
│ └── database/
└── live/
├── staging/
│ ├── network/
│ ├── cluster/
│ └── database/
└── production/
├── network/
├── cluster/
└── database/Draw state boundaries deliberately
One state file per environment is better than one state file overall, but still too coarse. A plan that touches the network, the cluster and every database at once is slow to compute and hard to review.
Split state by rate of change and blast radius:
| Layer | Changes | Blast radius | Typical owner |
|---|---|---|---|
| Network | Rarely | Everything | Platform team |
| Cluster | Monthly | All workloads | Platform team |
| Databases | Occasionally | One service | Service team |
| Service resources | Often | One service | Service team |
Each live directory then has its own backend configuration and its own state:
terraform {
backend "s3" {
bucket = "platform-terraform-state"
key = "production/database/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
encrypt = true
}
}With use_lockfile, Terraform 1.11 and later keep the state lock in the bucket itself. Older setups use a DynamoDB table for the same purpose.
Pass outputs, not assumptions
When layers are split, they need to share values: the database needs the network's subnet IDs. Avoid hard-coding them. Read them from the producing layer's outputs, or better, from a lookup that does not depend on another state file:
data "aws_subnets" "private" {
filter {
name = "tag:Tier"
values = ["private"]
}
}
module "database" {
source = "git::ssh://git@git.internal/platform/terraform-modules.git//database?ref=v1.8.0"
subnet_ids = data.aws_subnets.private.ids
instance = "db.r6g.large"
}Make the differences between environments explicit
Environments should differ only in a small, visible set of inputs: sizes, counts, feature switches. If production needs something staging does not have, that should be an input to the module, not a fork of it.
A useful test: if you diff two environment directories, every line of the output should be a deliberate decision.
Why not workspaces?
Terraform CLI workspaces give one configuration several state files, and they look like a shortcut to environments. They work well for short-lived copies that really are identical, such as a stack per feature branch.
For long-lived environments they hide the differences. Production and staging share one backend, one set of credentials and one directory. Every difference becomes a conditional on terraform.workspace, and a reviewer cannot tell from the diff which environment a change will touch. Separate live directories make that visible.
Automate the workflow around it
The layout pays off when plans and applies run in CI:
- A pull request triggers
planfor each affected directory. - The plan output is posted to the pull request for review.
- Merging applies the reviewed plan.
- A scheduled job runs
planagainst every directory and reports drift.
Summary
- Keep modules environment-agnostic and versioned.
- Split state by rate of change and blast radius.
- Share values through outputs or lookups, never copies.
- Make environment differences small, explicit inputs.
- Run everything through a reviewed, automated workflow.