Skip to content

The Multi-Environment Problem

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.

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:

Terminal window
# Three near-identical copies, created once by hand
tree environments
environments
├── dev
└── main.tf
├── staging
└── main.tf
└── prod
└── main.tf

On 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, verbatim
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.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 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)"]
Three copy-pasted environment directories quietly diverge as ad hoc fixes land in only one of them
What specifically goes wrong with copy-pasting an entire environment directory for every environment
What are the two competing goals that any multi-environment approach has to balance
Why does the copy-paste-per-environment problem tend to get worse over time rather than staying static
What does blast-radius isolation refer to in a multi-environment setup