Terraform Workspaces vs Directories#
You have dev, staging, and production. Same infrastructure, different sizes and settings. How do you organize Terraform? Two competing approaches:
- Workspaces: One set of
.tffiles, multiple state files selected byterraform workspace select - Directories: Separate directories per environment, each with their own
.tffiles and state
Both work. Both have tradeoffs. The right choice depends on how similar your environments are, who manages them, and how much isolation you need.
Workspaces: One Config, Multiple States#
infrastructure/
├── main.tf # shared config
├── variables.tf # shared variables
├── outputs.tf
├── backend.tf # state backend (key uses workspace name)
├── dev.tfvars # dev-specific values
├── staging.tfvars # staging-specific values
└── prod.tfvars # prod-specific values# backend.tf — workspace name becomes part of the state key
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
# State stored as: env:/dev/infrastructure/terraform.tfstate
# State stored as: env:/staging/infrastructure/terraform.tfstate
# State stored as: env:/prod/infrastructure/terraform.tfstate
}
}# main.tf — environment differences via variables
locals {
env_config = {
dev = {
instance_type = "t3.small"
node_count = 1
multi_az = false
}
staging = {
instance_type = "t3.medium"
node_count = 2
multi_az = false
}
prod = {
instance_type = "t3.large"
node_count = 3
multi_az = true
}
}
config = local.env_config[terraform.workspace]
}
resource "aws_instance" "app" {
count = local.config.node_count
instance_type = local.config.instance_type
# ...
}# Usage
terraform workspace select dev
terraform plan -var-file=dev.tfvars
terraform workspace select prod
terraform plan -var-file=prod.tfvarsWorkspace Advantages#
- DRY: One set of files, no code duplication
- Consistency: All environments use exactly the same resource definitions
- Simple: Less directory structure to navigate
- Atomic changes: A code change is applied to all environments (after promotion)
Workspace Problems#
- Accidental wrong workspace:
terraform applyin the wrong workspace changes the wrong environment. The most dangerous failure mode — there is no safety net if you forget toworkspace select - No structural differences: If prod needs a resource that dev does not, you need conditional logic (
count = terraform.workspace == "prod" ? 1 : 0) - State backend coupling: All environments share the same backend configuration. Cannot give dev team access to dev state without also giving them the staging/prod state path
- Plan applies everywhere: A broken change in the shared config affects all environments when applied
- Hard to review: PR shows changes to a single config — reviewer cannot easily tell what changes in dev vs prod
Directories: Separate Configs Per Environment#
infrastructure/
├── modules/
│ └── app-stack/ # shared module
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── dev/
│ ├── main.tf # calls module with dev settings
│ ├── backend.tf # dev state backend
│ └── terraform.tfvars
├── staging/
│ ├── main.tf # calls module with staging settings
│ ├── backend.tf # staging state backend
│ └── terraform.tfvars
└── prod/
├── main.tf # calls module with prod settings
├── backend.tf # prod state backend
└── terraform.tfvars# dev/main.tf
module "app" {
source = "../modules/app-stack"
environment = "dev"
instance_type = "t3.small"
node_count = 1
multi_az = false
}# prod/main.tf
module "app" {
source = "../modules/app-stack"
environment = "prod"
instance_type = "t3.large"
node_count = 3
multi_az = true
# Prod-only resources
enable_waf = true
enable_backups = true
}Directory Advantages#
- Isolation: Each environment has its own state file, backend config, and access controls
- Safety: Cannot accidentally apply to the wrong environment (you are in the directory, there is no workspace to forget)
- Flexibility: Prod can have resources that dev does not, without conditional logic
- Independent applies: Dev team can apply freely without affecting staging/prod state locks
- Clear review: PR to
prod/main.tfclearly affects production
Directory Problems#
- Code duplication: Environment-specific
main.tffiles repeat boilerplate - Drift risk: Environments can diverge if module calls are updated in one but not another
- More files: Directory structure is larger and more complex
- Module versioning: Must coordinate module changes across all environments
Decision Framework#
| Factor | Use Workspaces | Use Directories |
|---|---|---|
| Environments are identical (same resources, different sizes) | Yes | Either |
| Environments differ structurally (prod has WAF, dev does not) | No | Yes |
| Separate teams manage different environments | No | Yes |
| Need different access controls per environment | No | Yes |
| Small team, simple infrastructure | Yes | Either |
| Large team, complex infrastructure | No | Yes |
| CI/CD pipeline per environment | Either | Easier |
| Compliance requires state isolation | No | Yes |
The Short Answer#
- Workspaces: When all environments are structurally identical and managed by the same team. Essentially a loop over the same config with different variable values.
- Directories: When environments differ, when different people manage different environments, or when you need access control isolation between environments.
- Most teams end up with directories because environments inevitably diverge.
The Hybrid Pattern#
Use directories for environment isolation but modules to share code:
infrastructure/
├── modules/
│ ├── networking/ # shared networking module
│ ├── database/ # shared database module
│ └── compute/ # shared compute module
├── dev/
│ ├── main.tf # composes modules for dev
│ └── backend.tf
├── staging/
│ ├── main.tf # composes modules for staging
│ └── backend.tf
└── prod/
├── main.tf # composes modules for prod
├── waf.tf # prod-only WAF configuration
└── backend.tfThis gives you:
- Directory isolation (safety, access control, independent state)
- Module reuse (DRY where it matters — resource definitions)
- Flexibility (prod can have
waf.tfthat other environments do not)
Feature Branch Infrastructure#
Temporary environments created per feature branch, destroyed when the branch is merged.
Workspace Approach#
# Create workspace named after the branch
BRANCH=$(git branch --show-current | tr '/' '-')
terraform workspace new "$BRANCH" || terraform workspace select "$BRANCH"
terraform apply -var-file=dev.tfvars -var="name_suffix=$BRANCH"
# After merge: destroy and delete workspace
terraform workspace select "$BRANCH"
terraform destroy -auto-approve
terraform workspace select default
terraform workspace delete "$BRANCH"Directory Approach with Templating#
# Copy dev directory as template
cp -r infrastructure/dev "infrastructure/feature-$BRANCH"
# Update backend key to use branch name
sed -i "s/dev\\/terraform.tfstate/feature-$BRANCH\\/terraform.tfstate/" \
"infrastructure/feature-$BRANCH/backend.tf"
terraform -chdir="infrastructure/feature-$BRANCH" init
terraform -chdir="infrastructure/feature-$BRANCH" apply
# After merge: destroy and remove directory
terraform -chdir="infrastructure/feature-$BRANCH" destroy -auto-approve
rm -rf "infrastructure/feature-$BRANCH"Recommendation for feature branches: Workspaces are simpler here. The environments are truly identical (same code, same structure), and the workspace lifecycle (create, use, destroy, delete) maps cleanly to the branch lifecycle.
Workspace Anti-Patterns#
Using Workspace Name in Resource Names#
# Fragile — workspace name might not be valid for all resource types
resource "aws_s3_bucket" "data" {
bucket = "myorg-${terraform.workspace}-data"
# What if workspace is "feature/my-branch"? Forward slash is invalid.
}Fix: Use a separate environment variable, not terraform.workspace:
variable "environment" {
type = string
}
resource "aws_s3_bucket" "data" {
bucket = "myorg-${var.environment}-data"
}Workspaces for Fundamentally Different Environments#
# Anti-pattern: conditional everything because environments differ too much
resource "aws_waf_web_acl" "main" {
count = terraform.workspace == "prod" ? 1 : 0
# ...
}
resource "aws_cloudwatch_alarm" "billing" {
count = terraform.workspace == "prod" ? 1 : 0
# ...
}
resource "aws_shield_subscription" "main" {
count = terraform.workspace == "prod" ? 1 : 0
# ...
}
# When you have 10+ conditional resources, switch to directories.Forgetting Which Workspace Is Active#
# This happens to everyone eventually
terraform workspace select dev
# ... do other work, switch terminals, come back ...
terraform apply # which workspace is this? hope it's not prod
# Prevention: show workspace in shell prompt
export PS1='[\$(terraform workspace show 2>/dev/null || echo "no-tf")] \w\$ 'State Backend Patterns#
Workspaces: Single Backend Config#
# All workspaces share the same bucket, different keys
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "app/terraform.tfstate"
region = "us-east-1"
# Workspace state: s3://myorg-terraform-state/env:/dev/app/terraform.tfstate
}
}Directories: Separate Backend Configs#
# dev/backend.tf
terraform {
backend "s3" {
bucket = "myorg-terraform-state-dev" # separate bucket
key = "app/terraform.tfstate"
region = "us-east-1"
}
}
# prod/backend.tf
terraform {
backend "s3" {
bucket = "myorg-terraform-state-prod" # separate bucket, separate access policy
key = "app/terraform.tfstate"
region = "us-east-1"
}
}Separate buckets allow different IAM policies: dev team gets access to the dev bucket only. This is impossible with workspaces sharing a single bucket.
Agent Decision Workflow#
When an agent needs to set up environment isolation:
- Check environment count: 2-3 environments that are structurally identical → workspaces are fine
- Check structural differences: If prod needs resources dev doesn’t → directories
- Check team structure: Different teams managing different environments → directories
- Check compliance: Audit requirements for state isolation → directories
- Check existing patterns: If the codebase already uses one approach → follow it
- Default: When in doubt, use directories with shared modules. It is easier to start with directories than to migrate from workspaces to directories later.