The Multi-Environment Problem
The idea in one sentence
Section titled “The idea in one sentence”Every real infrastructure setup needs at least dev, staging, and prod as separate, isolated environments, and reusing the same Terraform logic across all of them without letting their configurations quietly drift apart is a problem Terraform does not solve for you automatically.
More environments than you think
Section titled “More environments than you think”A toy Terraform example usually manages one environment. A real one never does. At minimum you need:
- dev — cheap, disposable, safe to break.
- staging — a realistic rehearsal of prod, used to catch problems before they ship.
- prod — the one that serves real traffic and real customers.
Most teams end up with more subdivisions than that: a separate copy per AWS region, a separate copy per customer for larger B2B deployments, or a short-lived preview environment spun up per feature branch and torn down when the pull request merges. Each of these usually differs from the others in concrete ways — a smaller EC2 instance type in dev, fewer read replicas in staging, autoscaling turned off in dev to save money — and, critically, prod is often placed in an entirely separate AWS account or region from the rest, specifically so that a mistake in dev cannot reach anything that matters. That last point is called blast-radius isolation: limiting how far a bad apply or a leaked credential can actually travel.
The naive fix: copy-paste a directory per environment
Section titled “The naive fix: copy-paste a directory per environment”The first instinct almost everyone reaches for is to build one environment, get it working, then copy the whole directory for each additional environment:
# Three near-identical copies, created once by handtree environmentsenvironments├── dev│ └── main.tf├── staging│ └── main.tf└── prod └── main.tfOn day one, all three files are identical except for a handful of hardcoded values — an instance size here, a bucket name there. It works, and it feels fast: no shared modules to design, no abstractions to get right, just three folders of ordinary .tf code.
# Day one: dev/main.tf, staging/main.tf, and prod/main.tf are all this rule, verbatimresource "aws_security_group_rule" "allow_app_port" { type = "ingress" from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = ["10.0.0.0/16"] security_group_id = aws_security_group.app.id}Six months later, a security review flags that rule in prod as too permissive, and someone tightens it directly in prod/main.tf:
# Six months later: prod/main.tf was patched during a security review.# staging/main.tf and dev/main.tf still say 10.0.0.0/16 — nobody remembered to port the fix.resource "aws_security_group_rule" "allow_app_port" { type = "ingress" from_port = 8080 to_port = 8080 protocol = "tcp" cidr_blocks = ["10.0.5.0/24"] security_group_id = aws_security_group.app.id}Nobody did anything malicious or even careless here — fixing the rule where the security review happened to be looking was the reasonable thing to do in the moment. But the three directories are now three subtly different, undocumented configurations, and nothing in the repository says so. A diff between dev/ and prod/ six months later turns up a dozen small discrepancies, and nobody can say from memory which ones are intentional and which are just forgotten copy-paste debt.
The tension this module explores
Section titled “The tension this module explores”The underlying problem is that copy-pasting an entire environment directory optimizes for isolation — each environment truly cannot affect another, since they are separate files entirely — at the total expense of reuse. Every bug fix, every tag added for cost tracking, every module version bump has to be manually repeated in every copy, and manual repetition is exactly the kind of process step that silently stops happening.
The rest of this module is about resolving that tension: how do you reuse the same underlying logic across every environment (so there is one place to fix a bug, not three or five) while still keeping each environment’s state, credentials, and blast radius fully isolated from the others? Terraform on its own offers two partial answers — workspaces and the directory-per-environment pattern — and each one, as the next two lessons show, solves one half of the problem while leaving the other half exposed.
flowchart LR origin["Working config, copied once"] -->|copy-paste| dev["dev/ (copy)"] origin -->|copy-paste| staging["staging/ (copy)"] origin -->|copy-paste| prod["prod/ (copy)"] prod -->|"security rule tightened here only"| prodDrift["prod/ (drifted)"] staging -->|"instance size bumped here only"| stagingDrift["staging/ (drifted)"] dev -->|"never touched again"| devDrift["dev/ (stale)"]