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 .tf files, multiple state files selected by terraform workspace select
  • Directories: Separate directories per environment, each with their own .tf files 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.tfvars

Workspace 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 apply in the wrong workspace changes the wrong environment. The most dangerous failure mode — there is no safety net if you forget to workspace 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.tf clearly affects production

Directory Problems#

  • Code duplication: Environment-specific main.tf files 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.tf

This gives you:

  • Directory isolation (safety, access control, independent state)
  • Module reuse (DRY where it matters — resource definitions)
  • Flexibility (prod can have waf.tf that 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:

  1. Check environment count: 2-3 environments that are structurally identical → workspaces are fine
  2. Check structural differences: If prod needs resources dev doesn’t → directories
  3. Check team structure: Different teams managing different environments → directories
  4. Check compliance: Audit requirements for state isolation → directories
  5. Check existing patterns: If the codebase already uses one approach → follow it
  6. Default: When in doubt, use directories with shared modules. It is easier to start with directories than to migrate from workspaces to directories later.