How do you structure a multi-environment Terraform codebase (dev/staging/prod)?
Quick Answer
The common pattern is separate root modules per environment (`environments/dev`, `environments/staging`, `environments/prod`), each with its own backend config and `.tfvars`, all calling the *same* shared, versioned modules (`modules/vpc`, `modules/ecs-service`) so environments stay structurally consistent while differing only in variable values and module version pins. This keeps state files fully isolated per environment (so a mistake in dev can't touch prod), lets prod pin to an older, proven module version while dev tracks the latest, and avoids the blast-radius risk of one giant configuration managing every environment at once.
Detailed Answer
Structuring environments correctly is one of the most consequential Terraform architecture decisions a team makes — get it wrong and every mistake in dev risks touching prod.
The common pattern: separate root modules per environment
environments/
dev/
main.tf # calls shared modules, dev-specific backend
terraform.tfvars
backend.tf # state key: "dev/..."
staging/
main.tf
terraform.tfvars
backend.tf # state key: "staging/..."
prod/
main.tf
terraform.tfvars
backend.tf # state key: "prod/..."
modules/
vpc/
ecs-service/
rds/
Each environment directory is its own root module with its own state file (via a distinct backend key or even a separate backend entirely), its own .tfvars for environment-specific values, and — critically — its own module version pins, so environments/prod/main.tf can call source = "../../modules/vpc" ; version = "2.3.0" while environments/dev/main.tf tracks "~> 2.4" to test upcoming changes safely.
Why this beats alternatives
- A single workspace-per-environment setup shares one codebase and one set of provider credentials across all workspaces, with no clean way to point different workspaces at different AWS accounts or pin different module versions per environment (see the earlier workspaces question).
- One giant configuration with
if environment == "prod"conditionals everywhere couples every environment's blast radius together — a bug in the conditional logic, or a badapply, can affect all environments from a singleterraform applyrun.
Benefits of the separate-root-module approach
- Isolated state — a broken
applyin dev cannot corrupt or lock prod's state, since they're entirely separate state files (and often separate backend buckets/accounts). - Independent module version pins — prod can stay on a proven module version while dev/staging validate the next one.
- Environment-specific access control — CI pipelines can require stronger approval gates and more restrictive IAM roles specifically for the
proddirectory. - Structural consistency without duplication — since all environments call the same shared modules, they can't structurally drift apart the way three independently hand-written configurations would.
The main cost is some duplication in the environment directories' wiring code (each main.tf still has to call the same modules with different variables) — teams often manage this with tools like Terragrunt to reduce that boilerplate further.