Multi-Environment Setup
Almost every real system needs more than one environment: a dev you can break freely, a staging that mirrors production, and a prod you guard carefully. The goal is to keep these environments identical in shape but isolated in blast radius — they should run the same infrastructure code but never share state, credentials, or the ability to touch each other. The cleanest way to achieve this in Terraform is a layout of shared modules wrapped by thin per-environment root configurations, each with its own backend and its own .tfvars. This page walks through that layout, how to isolate state, and how to promote a change from dev to prod without an accident.
Why shared modules plus per-env roots
The two extremes both fail. A single configuration with count/if conditionals on the environment name quickly becomes unreadable and couples every environment together. Fully copy-pasted directories drift apart silently. The middle path keeps the logic in one reusable module and the configuration in tiny per-environment roots that only set inputs and a backend.
| Approach | State isolation | Drift risk | Readability |
|---|---|---|---|
| CLI workspaces | Same backend, separate keys | Low | Conditionals creep in |
| Shared module + per-env roots | Separate backend per env | Low | High |
| Copy-pasted directories | Separate backend per env | High | Degrades over time |
The shared-module approach gives true backend isolation while keeping the code DRY. It works identically in OpenTofu — swap terraform for tofu throughout.
A worked directory layout
Put all the actual resource definitions in a module under modules/. Each environment gets a directory under environments/ containing only a backend, a provider, a call to the module, and a .tfvars file.
.
├── modules/
│ └── service/
│ ├── main.tf # all resources live here
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── dev/
│ ├── main.tf # calls ../../modules/service
│ ├── backend.tf
│ └── dev.tfvars
├── staging/
│ ├── main.tf
│ ├── backend.tf
│ └── staging.tfvars
└── prod/
├── main.tf
├── backend.tf
└── prod.tfvars
The shared module declares its inputs and the resources it builds:
# modules/service/variables.tf
variable "environment" {
type = string
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "min_size" {
type = number
default = 1
}
# modules/service/main.tf
resource "aws_instance" "api" {
ami = "ami-0c7217cdde317cfec"
instance_type = var.instance_type
tags = {
Name = "api-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
Each environment root is deliberately thin — it wires inputs to the module and nothing else:
# environments/prod/main.tf
provider "aws" {
region = "us-east-1"
}
module "service" {
source = "../../modules/service"
environment = var.environment
instance_type = var.instance_type
min_size = var.min_size
}
variable "environment" { type = string }
variable "instance_type" { type = string }
variable "min_size" { type = number }
The differences between environments live entirely in the .tfvars:
# environments/prod/prod.tfvars
environment = "prod"
instance_type = "m5.large"
min_size = 3
# environments/dev/dev.tfvars
environment = "dev"
instance_type = "t3.micro"
min_size = 1
Isolating state per environment
The single most important guarantee is that each environment has its own state in its own backend. A mistake in one environment’s backend or credentials must not be able to corrupt another. Give every environment a distinct backend key — and ideally a distinct cloud account.
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "devcraftly-tfstate-prod"
key = "service/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks-prod"
encrypt = true
}
}
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "devcraftly-tfstate-dev"
key = "service/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks-dev"
encrypt = true
}
}
Warning: Do not store dev, staging, and prod state in one bucket distinguished only by key. Separate buckets (and separate accounts) mean a leaked dev credential cannot read or overwrite prod state.
You operate on one environment at a time by cd-ing into its directory and passing its var file:
cd environments/dev
terraform init
terraform plan -var-file=dev.tfvars
terraform apply -var-file=dev.tfvars
Output:
Terraform will perform the following actions:
# module.service.aws_instance.api will be created
+ resource "aws_instance" "api" {
+ ami = "ami-0c7217cdde317cfec"
+ instance_type = "t3.micro"
+ tags = {
+ "Environment" = "dev"
+ "Name" = "api-dev"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Promoting changes safely
Promotion is the discipline of applying the same module change to environments in order: dev → staging → prod. Because all environments call the same module, a change merged into modules/service is automatically picked up everywhere — you just apply it environment by environment and only advance once the previous tier is green.
For stronger control, pin the module to a version so prod does not silently track the latest commit:
module "service" {
source = "git::https://github.com/devcraftly/infra.git//modules/service?ref=v1.4.0"
# prod can stay on v1.3.0 until v1.4.0 is proven in staging
}
The promotion gate is simply: review the plan, apply to the lower tier, verify, then bump the next tier’s ref. A wrapper script makes the order explicit:
for env in dev staging prod; do
echo "==> planning $env"
terraform -chdir="environments/$env" plan -var-file="$env.tfvars"
done
Avoiding prod accidents
Most production incidents come from running the right command in the wrong place. Defend against it structurally rather than relying on memory.
Tip: Add a
prevent_destroylifecycle block to critical prod resources so even a mistargetedterraform destroyfails loudly instead of deleting them.
resource "aws_db_instance" "main" {
identifier = "service-${var.environment}"
instance_class = "db.r6g.large"
engine = "postgres"
lifecycle {
prevent_destroy = true
}
}
Require prod applies to go through CI with a manual approval, never an engineer’s laptop, and assume a distinct IAM role per environment so dev credentials literally cannot reach prod.
Best Practices
- Keep all resource logic in shared modules; let per-environment roots set only inputs and a backend.
- Give every environment its own backend (separate bucket, lock table, and ideally cloud account) for true blast-radius isolation.
- Drive every difference between environments through
.tfvars, not conditionals on the environment name. - Pin module versions with a
refso prod advances deliberately rather than tracking the latest commit. - Promote in order — dev, then staging, then prod — and only advance after verifying the previous tier.
- Protect critical prod resources with
prevent_destroyand run prod applies through approved CI, not local machines. - Use a distinct IAM role per environment so lower-tier credentials cannot touch production.