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 Azure 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 VM size in dev, fewer replicas in staging, autoscaling turned off in dev to save money. Critically, prod is often placed in its own Azure resource group, or even its own subscription, entirely separate 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 — a VM size here, a resource group 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 "azurerm_network_security_rule" "allow_app_port" { name = "allow-app-port" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "8080" source_address_prefix = "10.0.0.0/16" destination_address_prefix = "*" resource_group_name = azurerm_resource_group.app.name network_security_group_name = azurerm_network_security_group.app.name}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 "azurerm_network_security_rule" "allow_app_port" { name = "allow-app-port" priority = 100 direction = "Inbound" access = "Allow" protocol = "Tcp" source_port_range = "*" destination_port_range = "8080" source_address_prefix = "10.0.5.0/24" destination_address_prefix = "*" resource_group_name = azurerm_resource_group.app.name network_security_group_name = azurerm_network_security_group.app.name}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 -->|"NSG rule tightened here only"| prodDrift["prod/ (drifted)"] staging -->|"VM size bumped here only"| stagingDrift["staging/ (drifted)"] dev -->|"never touched again"| devDrift["dev/ (stale)"]
Both azurerm_network_security_rule fences above are real, valid HCL — confirmed with terraform fmt -check against each — the drift shown between them is not a syntax difference, only a value one (source_address_prefix), exactly the kind of small, easy-to-miss divergence that accumulates silently in a copy-pasted tree.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated environments/ tree, check for:
- Three (or more) environment directories that are byte-identical except for a handful of hardcoded values, with no shared module or shared config file anywhere — the exact copy-paste pattern this lesson describes, and the single most common starting point an AI assistant produces when asked for “a dev, staging, and prod setup.”
- A hardcoded value (a VM size, an NSG rule prefix, a tag) that differs between environment directories with no comment explaining why — indistinguishable from accidental drift versus an intentional environment-specific choice.
- No mention of separate Azure subscriptions or resource groups per environment at all in a generated multi-environment example — a real blast-radius argument for Azure specifically depends on that separation, not just separate directories.
🐛 Spot the bug
# Generated "multi-environment setup" — three near-identical files# environments/dev/main.tf, environments/staging/main.tf, environments/prod/main.tfresource "azurerm_linux_virtual_machine" "app" {name = "app-vm"size = "Standard_B1s"tags = { environment = "prod"}}Show the answer
This exact resource block, unchanged, was generated for all three directories — including dev/main.tf and staging/main.tf, both hardcoding environment = "prod" in their tags. This is a realistic copy-paste artifact: the block was written once for prod and copied to the other two directories with nobody updating the one value that actually needed to differ per environment. Every cost report and every resource filter keyed on that tag now silently misattributes dev and staging spend to prod, and nothing in Terraform itself catches it — the config is perfectly valid HCL that applies cleanly in all three places.
Mental Model
Section titled “Mental Model”Copy-pasting a directory is optimizing for one property (isolation) by giving up another (a single source of truth) — there is no free lunch here, only a trade you can choose to make consciously or stumble into by accident. Every technique the rest of this module covers is really just a different point on the same isolation-versus-reuse line.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Copy-paste a directory per environment | Total isolation on day one, zero abstraction to design or learn | Every fix must be manually re-applied everywhere, proven above to silently drift within six months |
| A single shared config with no environment separation | Maximally DRY, one place to fix anything | No isolation at all — a mistake in one “environment” can reach all of them |
Common Misconceptions
Section titled “Common Misconceptions”- “Drift between environment copies means someone was careless.” Proven above: the prod fix was the reasonable, correct thing to do in the moment. The problem is structural — nothing in a copy-pasted tree propagates a fix.
- “Once the environments are copy-pasted and working, the risk is over.” The risk compounds with every subsequent change — it is not a one-time cost paid at setup, it is an ongoing tax.