Workspaces & Environments
Two strategies for dev / staging / prod — and how to pick.
Workspaces & Environments
Most projects need more than one copy of their infrastructure: dev, staging, prod. Two main strategies — pick one and stick with it.
Strategy 1: directory per environment
infra/
modules/
vpc/
web/
dev/
main.tf # uses modules with dev values
backend.tf # state at dev/terraform.tfstate
terraform.tfvars
staging/
...
prod/
...
Each environment is a separate root module with its own backend (and therefore its own state file). To apply staging:
cd staging/
terraform init
terraform plan
terraform apply
The configs in dev/, staging/, prod/ look almost identical — they call the same modules with different inputs. Some duplication is the cost of clarity.
Pros: environments are visually distinct; mistakes in one can't accidentally hit another; per-env state, per-env locking, per-env backend. Cons: changes to "the system" require touching N directories. Tools like Terragrunt exist to deduplicate the boilerplate.
This is the dominant pattern in production codebases.
Strategy 2: workspaces
Terraform's built-in workspaces keep one config but multiple state files:
terraform workspace new dev
terraform workspace new prod
terraform workspace select dev
terraform apply
terraform workspace select prod
terraform apply
Inside the config you can branch on terraform.workspace:
locals {
is_prod = terraform.workspace == "prod"
}
resource "aws_instance" "web" {
count = local.is_prod ? 5 : 1
}
Pros: one config, many states. Quick for terraform init / branching dev environments per-feature.
Cons: the workspace name is invisible if you're not careful — terraform apply in the wrong workspace happily destroys prod. Easy to mix up. Workspaces are best for ephemeral / per-developer envs, NOT for the dev/staging/prod hierarchy.
Backend per env
Whichever strategy, state must be separate per environment. A single state file holding both prod + dev means:
- A
terraform applycan accidentally touch prod when you meant dev. - Drift in dev shows up as plan noise on prod.
- A corrupted state takes down both.
With workspaces, the backend automatically nests state per workspace. With directory-per-env, every directory configures its own backend key.
Variables per env
The most common pattern:
dev/terraform.tfvars # dev defaults, gitignored if it has secrets
staging/terraform.tfvars
prod/terraform.tfvars
Or, with workspaces:
locals {
config = {
dev = { instance_count = 1, instance_type = "t3.micro" }
staging = { instance_count = 2, instance_type = "t3.small" }
prod = { instance_count = 5, instance_type = "m5.large" }
}
cfg = local.config[terraform.workspace]
}
resource "aws_instance" "web" {
count = local.cfg.instance_count
instance_type = local.cfg.instance_type
}
A single map keyed by env name. New environment? Add a key.
Terragrunt: the popular layer on top
Terragrunt is a thin wrapper around Terraform that deduplicates the directory-per-env pattern. You declare each environment in a terragrunt.hcl that points at a shared module + supplies the env-specific inputs. Backends, providers, and dependencies get auto-generated.
If you find yourself maintaining identical main.tf files across dev/, staging/, prod/, look at Terragrunt before writing a custom code-gen.
Don't share a workspace across teams
Workspaces are an organisational primitive WITHIN your config — they're not access control. Anyone with the credentials to apply your config can switch to any workspace. For real per-team / per-env isolation, use separate AWS accounts (or GCP projects, Azure subscriptions) and separate state backends. Terraform workspaces aren't a security boundary.
Tip
Whichever strategy you pick, name and label everything with the environment. Tagging every resource with Environment = var.environment (or terraform.workspace) means a misapplied stack is recognisable in the cloud console at a glance.