Multi-Environment Strategies

Workspaces vs separate state files vs separate directories for managing staging, production, and beyond.

The problem: one configuration, several environments

Almost every real project needs at least a staging and a production environment — often a per-developer sandbox too. All of them should run essentially the same infrastructure, just with different sizes, different names, and different levels of tolerance for risk. Terraform offers three genuinely different ways to structure this, and picking the wrong one for a given project is one of the most common sources of pain in a growing Terraform codebase. This page lays out all three side by side, since variables, modules & workspaces only introduced workspaces on their own.

Strategy 1: Workspaces

A single set of .tf files, with terraform workspace switching between multiple independent state files:

Bash
terraform workspace new staging
terraform workspace new production
terraform workspace select production
Hcl
resource "aws_instance" "app" {
  instance_type = terraform.workspace == "production" ? "t3.medium" : "t3.micro"

  tags = {
    Environment = terraform.workspace
  }
}

What it gets you: the least duplication of any of the three strategies — one copy of every resource block, ever. Switching environments is a single command, and it's easy to forget which one you're in, which is both the convenience and the danger.

Where it breaks down: workspaces share the exact same configuration logic across every environment. The moment production genuinely needs different infrastructure — a Multi-AZ database staging doesn't have, an extra caching layer, a different provider account entirely — you're forced to express that difference as conditionals sprinkled through otherwise-identical resource blocks (count = terraform.workspace == "production" ? 1 : 0 on an entire resource), which gets unreadable fast.

Strategy 2: Separate state files, same configuration

Keep one set of .tf files, but instead of Terraform-managed workspaces, point each environment at its own state file explicitly via backend configuration — often supplied per-environment at init time:

Hcl
# backend.tf — deliberately incomplete; values come from -backend-config
terraform {
  backend "s3" {
    bucket = "myapp-terraform-state"
    region = "us-east-1"
  }
}
Hcl
# backends/staging.hcl
key = "staging/terraform.tfstate"
Hcl
# backends/production.hcl
key = "production/terraform.tfstate"
Bash
terraform init -backend-config=backends/staging.hcl
# ...work in staging, then switch:
terraform init -reconfigure -backend-config=backends/production.hcl

Environment-specific values (instance sizes, domain names) then come from a separate .tfvars file per environment, exactly as covered in variables, modules & workspaces:

Bash
terraform apply -var-file="staging.tfvars"

What it gets you: genuinely separate state per environment (so a mistake in staging's state can't touch production's), while still sharing one copy of the actual resource logic — no terraform.workspace == conditionals needed, since the difference lives in .tfvars files instead.

Where it breaks down: it's easy to run a command against the wrong backend by forgetting to -reconfigure first, and unlike workspaces, there's no single command showing "which environment am I pointed at right now" — you have to check which backend config was last used.

Strategy 3: Separate directories per environment

The most explicit approach: a genuinely separate directory (and therefore a genuinely separate root configuration) per environment, each with its own backend and its own .tfvars, both typically calling the same shared modules:

Plaintext
infra/
├── modules/
│   └── web-app/              # shared logic, used by every environment
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   ├── staging/
│   │   ├── main.tf           # module "web_app" { source = "../../modules/web-app" ... }
│   │   ├── backend.tf        # its own S3 key
│   │   └── terraform.tfvars
│   └── production/
│       ├── main.tf
│       ├── backend.tf
│       └── terraform.tfvars
Hcl
# environments/production/main.tf
module "web_app" {
  source = "../../modules/web-app"

  instance_type    = "t3.medium"
  multi_az_db      = true
  enable_cdn       = true
  environment      = "production"
}
Hcl
# environments/staging/main.tf
module "web_app" {
  source = "../../modules/web-app"

  instance_type = "t3.micro"
  multi_az_db   = false
  enable_cdn    = false
  environment   = "staging"
}

What it gets you: environments can differ structurally, not just parametrically — production calling the module with multi_az_db = true and staging with false is trivial, because each environment is its own real configuration, not a conditional bolted onto a shared one. It's also the hardest strategy to run against the wrong environment by accident: you cd into the directory you mean to change, full stop.

Where it breaks down: genuine duplication exists at the root level (each environment's main.tf repeats the module call, just with different arguments), and a change to shared logic that needs a corresponding change in every environment's root file (a new required variable on the module, say) has to be applied to each directory individually.

Comparing all three

Workspaces Separate state, shared config Separate directories
Duplication None None (config), some (.tfvars) Some (root main.tf per env)
Environments can differ structurally Poorly — needs conditionals Poorly — same config runs everywhere Well — each is a real, independent config
Risk of running against the wrong environment Real — easy to forget the selected workspace Real — easy to forget which backend was last configured Low — you cd into the directory you mean
Best fit Environments that are near-identical, differing only in size/scale Environments sharing all logic, differing only in variables Environments that genuinely diverge, or where blast-radius isolation matters most

What most real teams land on

In practice, most production Terraform codebases beyond a small side project end up close to strategy 3 — separate directories per environment, each calling shared modules — precisely because production tends to accumulate real structural differences from staging over time (extra monitoring, stricter security groups, a read replica) that workspaces and shared-config-with-tfvars both struggle to express cleanly. Workspaces remain genuinely useful for a narrower case: many short-lived, near-identical environments, like one per feature-branch preview deployment, where the "structurally identical, differs only in a name and size" assumption actually holds.

Common mistakes

  • Reaching for workspaces by default and only discovering their conditional-heavy limitations once production has already diverged structurally from staging.
  • Forgetting which workspace or backend is currently selected and running apply against the wrong environment — always confirm with terraform workspace show or by checking the backend config before an apply that matters.
  • Duplicating entire resource blocks across environment directories instead of factoring the shared logic into a module that every environment's directory calls — this is what makes strategy 3 maintainable rather than a maintenance burden.
  • Mixing strategies inconsistently across a codebase (some environments as workspaces, others as directories) without a documented reason — pick one primary strategy per project and apply it uniformly.