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 GCP 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 Compute Engine machine type in dev, fewer Cloud SQL read replicas in staging, autoscaling turned off in dev to save money — and, critically, prod is often placed in an entirely separate GCP project from the rest, specifically so that a mistake in dev cannot reach anything that matters. A GCP project is a hard billing, IAM, and quota boundary, sitting under the same organization — the same isolation role a separate AWS account plays for teams on AWS. 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 machine type 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, verbatim
resource "google_compute_firewall" "allow_app_port" {
name = "allow-app-port"
network = "default"
allow {
protocol = "tcp"
ports = ["8080"]
}
source_ranges = ["10.0.0.0/16"]
}

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 "google_compute_firewall" "allow_app_port" {
name = "allow-app-port"
network = "default"
allow {
protocol = "tcp"
ports = ["8080"]
}
source_ranges = ["10.0.5.0/24"]
}

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 label 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)"]
Three copy-pasted environment directories quietly diverge as ad hoc fixes land in only one of them

Both google_compute_firewall 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_ranges).

  • Three (or more) environment directories that are byte-identical except for a handful of hardcoded values — 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 firewall CIDR, a machine type, a label) that differs between environment directories with no comment explaining why — indistinguishable from accidental drift versus an intentional environment-specific choice.
  • No mention of separate GCP projects per environment at all in a generated multi-environment example — a real blast-radius argument for GCP 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.tf
resource "google_compute_instance" "app" {
name = "app-vm"
machine_type = "e2-micro"
labels = {
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 labels. 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 label 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.

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.

ChoiceBenefitCost
Copy-paste a directory per environmentTotal isolation on day one, zero abstraction to design or learnEvery fix must be manually re-applied everywhere, proven above to silently drift within six months
A single shared config with no environment separationMaximally DRY, one place to fix anythingNo isolation at all
  • “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.
  • “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, it is an ongoing tax.
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
A generated "dev/staging/prod" example has the exact same google_compute_instance block copied into all three directories, with labels = { environment = "prod" } hardcoded in every copy. What is the real-world consequence, proven above?