Terraform — Workspaces and Environment Strategy
How to run the same infrastructure config for dev / staging / prod without copy-pasting — and the interview-relevant debate of workspaces vs separate directories.
The problem
You have one infrastructure definition. You need it deployed three times — dev, staging, prod — with different sizes, counts, and (critically) separate state so a plan in dev cannot possibly touch prod. How do you organize that?
Approach 1 — Terraform workspaces
terraform workspace creates multiple named states from the same config and same backend:
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod
terraform apply # uses the "prod" state, same .tf files
Inside the config you branch on terraform.workspace:
locals {
instance_count = terraform.workspace == "prod" ? 5 : 1
instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.micro"
}
State is stored at a workspace-suffixed key in the same backend (e.g. env:/prod/...).
Pros: zero directory duplication; one config to maintain.
Cons (and why many teams avoid workspaces for environments):
- Same backend — all environments’ state in one bucket; weaker isolation. The blast radius of a backend-level mistake spans every environment.
terraform.workspaceconditionals scatter environment logic through the config — easy to miss a branch, easy to apply prod settings to dev.- Easy to apply to the wrong environment —
workspace selectis stateful; forget to switch and youapplyto prod thinking you’re in dev. There’s no structural guard. - Workspaces were really designed for short-lived parallel copies (per-feature-branch ephemeral environments), not long-lived prod/staging.
Approach 2 — separate directories per environment (the common production choice)
environments/
dev/
main.tf # calls shared modules with dev values
backend.tf # backend "s3" { key = "dev/terraform.tfstate" }
terraform.tfvars # dev-specific values
staging/
main.tf
backend.tf # key = "staging/terraform.tfstate"
terraform.tfvars
prod/
main.tf
backend.tf # key = "prod/terraform.tfstate"
terraform.tfvars
modules/ # shared building blocks
vpc/
app/
Each environment is a root module with its own backend / own state file (separate key, often a separate bucket for prod). The shared modules/ keep it DRY; the per-environment .tfvars and main.tf supply the differences.
Pros:
- Strong isolation — prod has its own state file (and ideally its own bucket / account). A mistake in
dev/structurally cannot reach prod state. cd environments/prod && terraform apply— your location is the environment; no stateful “did I switch workspace?” footgun.- Per-environment backends — prod state can have stricter IAM, separate KMS key, even a separate AWS account.
- Environment differences live in
.tfvars(data), notterraform.workspaceconditionals (scattered logic).
Cons: some structural duplication (each environment has a main.tf and backend.tf) — but the modules are shared, so it’s wiring, not logic.
The interview answer
“Workspaces are good for ephemeral parallel environments — a temporary copy per feature branch. For long-lived dev/staging/prod, I prefer separate directories with separate backends: stronger state isolation, no ‘wrong workspace’ footgun, and prod can have its own bucket/account with tighter access. Shared modules keep it DRY.”
That nuance — workspaces for ephemeral, directories for permanent — is what they’re listening for.
Passing environment values
With the directory approach, environment differences are data in .tfvars:
# environments/prod/terraform.tfvars
environment = "prod"
instance_count = 5
instance_type = "m5.large"
db_multi_az = true
# environments/dev/terraform.tfvars
environment = "dev"
instance_count = 1
instance_type = "t3.micro"
db_multi_az = false
cd environments/prod
terraform apply -var-file=terraform.tfvars # (auto-loaded if named terraform.tfvars)
Secrets do not go in .tfvars committed to git — they come from environment variables (TF_VAR_db_password), or are pulled from Secrets Manager / SSM via data sources at plan time.
Per-environment AWS accounts
The strongest isolation: each environment is a separate AWS account (via AWS Organizations). Prod resources literally cannot be touched by dev credentials. The Terraform provider block assumes a per-environment role:
provider "aws" {
assume_role { role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/terraform" }
}
Common in mature setups; worth mentioning as “the gold standard for blast-radius control.”
State-key namespacing
Within a backend, the key path namespaces state — and you split not just by environment but by component:
prod/network/terraform.tfstate # VPC, subnets, routing
prod/data/terraform.tfstate # RDS, ElastiCache
prod/app/terraform.tfstate # ECS services, ALB
Why: a smaller state file = faster plan, smaller blast radius, and the lock only serializes that component. Cross-component references use terraform_remote_state data sources or (better) published outputs / SSM parameters. Don’t put the entire org in one state file.
Common gotchas
- Workspaces for prod/staging — the
workspace selectfootgun and shared-backend weak isolation. Fine for ephemeral, risky for permanent. - One state file for everything — slow plans, huge blast radius, lock contention. Split by environment and component.
- Secrets in
.tfvarsin git — useTF_VAR_env vars or pull from a secret store. - Drift between environments — dev and prod slowly diverge because changes were applied to one and not the other. Shared modules + a promotion pipeline (apply to dev → staging → prod from the same module version) keep them aligned.
- Forgetting
-var-file— if your tfvars isn’t namedterraform.tfvars/*.auto.tfvars, it isn’t auto-loaded; an apply without it uses defaults.
Interview angle
- “Workspaces or separate directories for environments?” — separate directories with separate backends for long-lived dev/staging/prod: stronger state isolation, no stateful ‘wrong workspace’ footgun, prod gets its own bucket/account. Workspaces are better suited to ephemeral parallel environments (per-feature-branch). Shared modules keep the directory approach DRY.
- “Why is one giant state file bad?” — slow
plan(re-reads everything), large blast radius (one mistake risks everything), and the lock serializes all infra work. Split state by environment and by component (network / data / app). - “How do environment differences get expressed?” — as data in per-environment
.tfvars(sizes, counts, flags), feeding shared modules — not asterraform.workspaceconditionals scattered through the config. Secrets come fromTF_VAR_env vars or a secret store, never committed tfvars. - “What’s the strongest environment isolation?” — separate AWS accounts per environment via Organizations; the Terraform provider assumes a per-environment role. Prod resources are structurally unreachable with dev credentials.
- “How do you keep dev and prod from drifting apart?” — shared modules pinned to versions, plus a promotion pipeline that applies the same module version through dev → staging → prod. Ad-hoc applies to one environment only are how drift starts.