On this page
Almost every Terraform codebase we inherit has the same history. It started with one environment. Then staging was needed, so someone copied the folder. Then a second region, so another copy. Two years later, there are four near-identical directories, each with small differences nobody can explain, and a change to the database module has to be made in four places and reviewed four times.
This post describes the structure we now use for clients with between two and ten environments on AWS. It keeps each environment's differences in one small file, isolates state so a mistake in development cannot touch production, and works with plain Terraform (or OpenTofu) without extra wrappers. We also cover when workspaces, Terragrunt or Stacks are the better choice.
What we are optimising for
Any multi-environment structure is a trade-off. These are the properties we care about, in rough order:
- Small blast radius. A
terraform applyin staging must not be able to change production. A change to DNS must not require planning the whole database layer. - One definition of each component. The VPC, the cluster and the database are defined once. Environments differ only in inputs.
- Differences are visible. Someone reviewing a pull request should see exactly how production differs from staging, in one place.
- Plain tooling. A new engineer should be able to run
terraform planwith nothing but Terraform and cloud credentials.
The options
| Approach | Separate state per env | Env differences visible | Extra tooling | Our view |
|---|---|---|---|---|
| Copy-pasted directories | Yes | No, buried in diffs | None | Drifts within months |
| Terraform CLI workspaces | Yes, same backend | Partly, via conditionals | None | Fine for identical, short-lived envs |
| Modules plus thin root per env | Yes | Yes, in tfvars | None | Our default |
| Terragrunt | Yes | Yes | Terragrunt | Good at large scale |
| HCP Terraform Stacks | Yes | Yes | HCP Terraform | Worth watching; depends on platform choice |
CLI workspaces look attractive because they reuse one directory. The problems appear later: all environments share one backend configuration and usually one set of credentials, and environment differences end up as terraform.workspace == "prod" ? ... : ... conditionals scattered through the code. It is also easy to run apply in the wrong workspace. We use workspaces for ephemeral preview environments that are identical by design, not for staging versus production.
The layout
infra/
modules/
network/
eks-cluster/
postgres/
service-iam/
live/
staging/
network/
main.tf
backend.hcl
terraform.tfvars
platform/
main.tf
backend.hcl
terraform.tfvars
production/
network/
platform/
shared/
providers.tf
versions.tfThere are two ideas here.
Modules hold all the logic. modules/ contains reusable, versioned components with inputs and outputs. They never reference an environment by name.
Live roots are thin. Each directory under live/ is a Terraform root module with its own state. Its main.tf is mostly module calls wired together; its terraform.tfvars holds the values that differ. The production and staging main.tf files are identical, and we check that in CI.
Roots are split by layer as well as environment: network (VPC, subnets, endpoints, DNS zones), platform (EKS, databases, caches) and, for some clients, an apps layer. Layers change at different rates and carry different risk. Nobody should have to plan the VPC to change a Redis parameter.
A live root
Here is the platform root. The same file lives in every environment.
# live/production/platform/main.tf
terraform {
backend "s3" {}
}
provider "aws" {
region = var.region
assume_role {
role_arn = var.deploy_role_arn
}
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Layer = "platform"
}
}
}
data "aws_ssm_parameter" "vpc_id" {
name = "/${var.environment}/network/vpc_id"
}
data "aws_ssm_parameter" "private_subnets" {
name = "/${var.environment}/network/private_subnet_ids"
}
module "cluster" {
source = "../../../modules/eks-cluster"
name = "${var.environment}-main"
kubernetes_version = var.kubernetes_version
vpc_id = data.aws_ssm_parameter.vpc_id.value
subnet_ids = split(",", data.aws_ssm_parameter.private_subnets.value)
}
module "postgres" {
source = "../../../modules/postgres"
identifier = "${var.environment}-orders"
instance_class = var.db_instance_class
multi_az = var.db_multi_az
allocated_storage = var.db_storage_gb
subnet_ids = split(",", data.aws_ssm_parameter.private_subnets.value)
}And the file that carries the difference:
# live/production/platform/terraform.tfvars
environment = "production"
region = "eu-west-1"
deploy_role_arn = "arn:aws:iam::111111111111:role/terraform-deploy"
kubernetes_version = "1.32"
db_instance_class = "db.r6g.2xlarge"
db_multi_az = true
db_storage_gb = 500Staging's tfvars is the same shape with smaller numbers, a different account ID and db_multi_az = false. A reviewer comparing environments diffs two short files.
Backends and state isolation
We use one AWS account per environment, and each account holds its own state bucket. That gives the strongest isolation: the credentials that can apply staging cannot read or write production state at all.
The backend block in main.tf is intentionally empty. Values come from a partial configuration file passed at init time:
# live/production/platform/backend.hcl
bucket = "example-tfstate-production"
key = "platform/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = truecd infra/live/production/platform
terraform init -backend-config=backend.hcl
terraform plan -out=tfplanSince Terraform 1.10, the S3 backend supports native state locking with use_lockfile = true, which writes a lock object next to the state file using S3 conditional writes. You no longer need a DynamoDB table for locking, and DynamoDB-based locking is deprecated. If you are on OpenTofu, 1.8 and later also allow variables and locals in backend configuration through early evaluation, which can remove the separate backend.hcl altogether.
(The bucket name above is a placeholder. Bucket names are global, so include something unique to your organisation.)
Passing values between layers
The platform layer needs the VPC ID from the network layer. There are two common ways to do this.
terraform_remote_state reads the other layer's state file directly. It works, but it gives the consumer read access to the whole state, including any secrets in it, and it couples the two layers to each other's output names.
We prefer publishing a small, explicit contract. The network layer writes the values other layers need to SSM Parameter Store, and consumers read them with aws_ssm_parameter data sources, as in the example above.
# live/production/network/outputs.tf
resource "aws_ssm_parameter" "vpc_id" {
name = "/${var.environment}/network/vpc_id"
type = "String"
value = module.network.vpc_id
}
resource "aws_ssm_parameter" "private_subnet_ids" {
name = "/${var.environment}/network/private_subnet_ids"
type = "StringList"
value = join(",", module.network.private_subnet_ids)
}This also means non-Terraform consumers, such as deployment scripts or Helm values, can read the same parameters.
Module versioning
For a single team, referencing modules by relative path is fine. Every environment picks up changes on the next apply, and the promotion path is simply "apply staging, then production".
Once several teams share modules, pin versions. We tag the modules repository and reference tags from the live roots:
module "postgres" {
source = "git::https://github.com/example-org/terraform-modules.git//postgres?ref=postgres-v2.4.0"
# ...
}Promotion then becomes explicit: bump the ref in staging, apply, verify, bump it in production. The pull request that changes production shows exactly which module version it moves to.
CI
Every pull request runs terraform fmt -check, terraform validate and a plan for every root it touches. We detect touched roots with a path filter on live/** and modules/**; a module change triggers plans in every root that uses it. Plans are posted to the pull request as a comment.
Applies run on merge, staging first. Production applies require a manual approval in the CI system, and use a deploy role that only the production pipeline can assume. Engineers do not have standing write access to production state.
We also run a scheduled nightly plan against every root. Any non-empty plan means something changed outside Terraform, and it opens a ticket.
When to reach for something else
Terragrunt is worth it when you have dozens of roots, many accounts and regions, and the repetition in main.tf and backend files becomes the problem. It generates backend configuration, handles dependencies between roots, and can run plans across many of them at once. The cost is another tool every engineer must learn.
HCP Terraform Stacks model multiple deployments of the same configuration natively. If you are already on HCP Terraform, it is worth evaluating. If you are not, adopting a platform to get one feature is a bigger decision than this post can settle.
What we'd do differently
- Split state by layer from the start. Splitting a large state later means a series of
terraform state mvoperations ormovedandimportblocks, and it is slow, careful work. - Use one account per environment from day one. Retrofitting account boundaries touches IAM, networking and DNS at once.
- Add the "live roots must be identical" CI check early. Without it, someone will eventually add a production-only resource directly to production's
main.tf, and the structure starts to erode. - Keep secrets out of state where possible, generating them in Secrets Manager and passing references instead of values.
Checklist
- All logic lives in modules that never mention an environment by name.
- Each environment and layer is its own root with its own state.
- Environment differences live only in
terraform.tfvars. - State lives in a per-account bucket with encryption and
use_lockfile = true. - Layers share values through SSM parameters, not by reading each other's state.
- Shared modules are version-pinned and promoted explicitly.
- CI runs fmt, validate and plan on every touched root, and posts the plan.
- Production applies need approval and a dedicated role.
- A nightly plan catches drift.
None of this requires anything beyond Terraform itself. It is mostly the discipline of keeping one definition per component and pushing every difference into a file small enough to read in a code review.



