DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on Originally published at actocodes.hashnode.dev

Multi-Environment Terraform: How to Structure Workspaces for Dev, Staging, and Production

Every infrastructure codebase eventually confronts the same problem, you need dev, staging, and production environments that are structurally identical but configured differently, different instance sizes, different replica counts, different domain names, different secrets, and critically, different state files that must never cross-contaminate.

How you solve this problem determines the maintainability of your infrastructure for years. Get it right and promoting a configuration change from dev to staging to production is a deliberate, reviewable, auditable workflow. Get it wrong and you have three copies of the same Terraform module drifting apart, environment-specific hacks embedded in shared code, and a lingering fear that terraform apply in the wrong directory will touch production.

Terraform offers two primary approaches: workspaces (single configuration, multiple state files) and directory-based layouts (separate directories per environment). Both are valid, neither is universally correct. Understanding their trade-offs, and when to combine them, is the skill this guide develops.

Approach 1 - Terraform Workspaces

Terraform workspaces allow a single configuration to maintain multiple independent state files. The workspace name is available as terraform.workspace inside configuration, enabling environment-specific branching:

# main.tf - single configuration, workspace-aware
locals {
  environment = terraform.workspace   # "dev", "staging", "production"

  config = {
    dev = {
      instance_type  = "t3.small"
      replica_count  = 1
      min_nodes      = 1
      max_nodes      = 3
    }
    staging = {
      instance_type  = "t3.medium"
      replica_count  = 2
      min_nodes      = 2
      max_nodes      = 5
    }
    production = {
      instance_type  = "m6i.large"
      replica_count  = 3
      min_nodes      = 3
      max_nodes      = 20
    }
  }
}

resource "aws_instance" "app_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = local.config[local.environment].instance_type

  tags = {
    Environment = local.environment
    ManagedBy   = "terraform"
  }
}
Enter fullscreen mode Exit fullscreen mode

Working with workspaces:

# Create environments
terraform workspace new dev
terraform workspace new staging
terraform workspace new production

# List workspaces
terraform workspace list
#   default
# * dev
#   staging
#   production

# Switch and apply
terraform workspace select staging
terraform plan
terraform apply

# Each workspace has its own state file in the backend
# terraform.tfstate.d/dev/terraform.tfstate
# terraform.tfstate.d/staging/terraform.tfstate
# terraform.tfstate.d/production/terraform.tfstate
Enter fullscreen mode Exit fullscreen mode

When Workspaces Work Well

Workspaces shine for environments that are structurally identical with minor configuration differences, the same resources, same modules, same topology, different sizes. A simple web application with three environments of the same architecture is an ideal workspace candidate.

Workspace Limitations

No access control between workspaces. Anyone with Terraform access to the configuration can apply to any workspace, including production. There is no native mechanism to require different approval processes for different workspaces.

Drift between environments is easy to introduce. A conditional block added for production (if terraform.workspace == "production") that grows over time creates structural differences that erode the "identical architecture" premise workspaces rely on.

Config-in-code environment branching becomes unwieldy. When local.config maps grow to cover 20+ configuration values across three environments, they become difficult to read and error-prone to maintain.

Approach 2 - Directory-Based Layout

The directory-based approach gives each environment its own Terraform root module, its own main.tf, variables.tf, outputs.tf, and backend configuration, while sharing infrastructure logic through reusable modules:

/infrastructure
  /modules                    ← shared, reusable infrastructure logic
    /vpc/
      main.tf
      variables.tf
      outputs.tf
    /eks/
      main.tf
      variables.tf
      outputs.tf
    /rds/
      main.tf
      variables.tf
      outputs.tf
  /environments               ← environment-specific root modules
    /dev/
      main.tf                 ← composes modules with dev config
      variables.tf
      outputs.tf
      backend.tf
      terraform.tfvars        ← dev-specific values
    /staging/
      main.tf
      variables.tf
      outputs.tf
      backend.tf
      terraform.tfvars
    /production/
      main.tf
      variables.tf
      outputs.tf
      backend.tf
      terraform.tfvars
Enter fullscreen mode Exit fullscreen mode

Each environment directory is a complete, independent Terraform root module. Shared infrastructure logic lives in /modules and is consumed by all environments.

Module Design for Reuse

# modules/rds/main.tf - reusable RDS module
variable "identifier"          { type = string }
variable "instance_class"      { type = string }
variable "allocated_storage"   { type = number }
variable "environment"         { type = string }
variable "vpc_id"              { type = string }
variable "subnet_ids"          { type = list(string) }
variable "deletion_protection" { type = bool    default = false }
variable "backup_retention"    { type = number  default = 7 }
variable "multi_az"            { type = bool    default = false }

resource "aws_db_instance" "this" {
  identifier          = var.identifier
  engine              = "postgres"
  engine_version      = "16.1"
  instance_class      = var.instance_class
  allocated_storage   = var.allocated_storage
  storage_encrypted   = true
  deletion_protection = var.deletion_protection
  backup_retention_period = var.backup_retention
  multi_az            = var.multi_az

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

output "endpoint"    { value = aws_db_instance.this.endpoint }
output "instance_id" { value = aws_db_instance.this.id }
Enter fullscreen mode Exit fullscreen mode

Environment Root Modules

# environments/dev/main.tf
module "vpc" {
  source = "../../modules/vpc"

  name                 = "dev-vpc"
  cidr                 = "10.0.0.0/16"
  availability_zones   = ["us-east-1a", "us-east-1b"]
  private_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnet_cidrs  = ["10.0.101.0/24", "10.0.102.0/24"]
  environment          = "dev"
}

module "rds" {
  source = "../../modules/rds"

  identifier          = "orders-dev"
  instance_class      = "db.t3.micro"   # smallest for dev
  allocated_storage   = 20
  deletion_protection = false            # allow easy teardown in dev
  backup_retention    = 1               # minimal backups in dev
  multi_az            = false           # single-AZ for dev
  environment         = "dev"
  vpc_id              = module.vpc.vpc_id
  subnet_ids          = module.vpc.private_subnet_ids
}
Enter fullscreen mode Exit fullscreen mode
# environments/production/main.tf
module "vpc" {
  source = "../../modules/vpc"

  name                 = "production-vpc"
  cidr                 = "10.2.0.0/16"
  availability_zones   = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnet_cidrs = ["10.2.1.0/24", "10.2.2.0/24", "10.2.3.0/24"]
  public_subnet_cidrs  = ["10.2.101.0/24", "10.2.102.0/24", "10.2.103.0/24"]
  environment          = "production"
}

module "rds" {
  source = "../../modules/rds"

  identifier          = "orders-production"
  instance_class      = "db.r6g.xlarge"   # production-grade
  allocated_storage   = 100
  deletion_protection = true              # never accidentally destroy
  backup_retention    = 30               # 30 days of backups
  multi_az            = true             # high availability
  environment         = "production"
  vpc_id              = module.vpc.vpc_id
  subnet_ids          = module.vpc.private_subnet_ids
}
Enter fullscreen mode Exit fullscreen mode

Isolated Backend Configuration

Each environment gets its own backend, a separate S3 key ensures state files are completely independent:

# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "your-org-terraform-state"
    key            = "environments/dev/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}
Enter fullscreen mode Exit fullscreen mode
# environments/production/backend.tf
terraform {
  backend "s3" {
    bucket         = "your-org-terraform-state"
    key            = "environments/production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}
Enter fullscreen mode Exit fullscreen mode

Separate state files with separate DynamoDB lock tables (or the same table with different keys) ensure no operation on dev can interfere with production state.

Variable Management Across Environments

terraform.tfvars per Environment

Each environment directory contains a terraform.tfvars with environment-specific values:

# environments/dev/terraform.tfvars
environment      = "dev"
aws_region       = "us-east-1"
app_version      = "2.4.1-dev"
alert_email      = "dev-alerts@your-org.com"
Enter fullscreen mode Exit fullscreen mode
# environments/production/terraform.tfvars
environment      = "production"
aws_region       = "us-east-1"
app_version      = "2.4.1"
alert_email      = "pagerduty@your-org.com"
Enter fullscreen mode Exit fullscreen mode

Sensitive Variables via Environment Variables

Secrets never appear in terraform.tfvars, inject them as TF_VAR_ prefixed environment variables at CI/CD runtime:

# In CI/CD pipeline — injected from secrets manager
export TF_VAR_db_password="$(vault kv get -field=password secret/production/rds)"
export TF_VAR_api_key="$(vault kv get -field=key secret/production/stripe)"

terraform apply -var-file=terraform.tfvars
Enter fullscreen mode Exit fullscreen mode
# environments/production/variables.tf
variable "db_password" {
  type      = string
  sensitive = true   # prevents value from appearing in plan output or state
}

variable "api_key" {
  type      = string
  sensitive = true
}
Enter fullscreen mode Exit fullscreen mode

The sensitive = true flag masks the variable value in terraform plan output, a critical safeguard when plans are posted to pull requests or logged in CI systems.

Choosing Between Workspaces and Directories

Neither approach is universally superior. Use this decision framework:

Factor Workspaces Directory-Based
Environments are structurally identical Ideal More boilerplate
Environments have significant config differences Awkward branching Clean separation
Different access controls per environment Not supported natively Enforce via CI/CD
Team size Small Medium to large
Number of environments 2–3 3+
Compliance requirement for state isolation Soft isolation Hard isolation
Risk of applying to wrong environment Higher Lower

Recommended default: directory-based layout for any environment where production carries business risk. The additional boilerplate is a worthwhile trade for explicit isolation, clear ownership, and CI/CD-enforced promotion workflows.

CI/CD Pipeline for Environment Promotion

The promotion workflow, change approved in dev, promoted to staging, promoted to production, is where directory-based layouts shine. Each environment is an independent pipeline target:

# .github/workflows/terraform-promote.yml
name: Terraform Environment Promotion

on:
  push:
    branches: [main]
    paths:
      - 'infrastructure/environments/**'
      - 'infrastructure/modules/**'

jobs:
  plan-dev:
    name: Plan — Dev
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan — Dev
        working-directory: infrastructure/environments/dev
        run: |
          terraform init
          terraform plan -var-file=terraform.tfvars -out=plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}

      - name: Upload plan artifact
        uses: actions/upload-artifact@v4
        with:
          name: dev-plan
          path: infrastructure/environments/dev/plan.tfplan

  apply-dev:
    name: Apply — Dev
    runs-on: ubuntu-latest
    needs: plan-dev
    environment: dev   # GitHub environment with optional approval
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Download plan
        uses: actions/download-artifact@v4
        with:
          name: dev-plan
          path: infrastructure/environments/dev

      - name: Terraform Apply — Dev
        working-directory: infrastructure/environments/dev
        run: |
          terraform init
          terraform apply plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}

  plan-staging:
    name: Plan — Staging
    runs-on: ubuntu-latest
    needs: apply-dev   # staging only plans after dev applies successfully
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan — Staging
        working-directory: infrastructure/environments/staging
        run: |
          terraform init
          terraform plan -var-file=terraform.tfvars -out=plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }}

      - name: Upload plan artifact
        uses: actions/upload-artifact@v4
        with:
          name: staging-plan
          path: infrastructure/environments/staging/plan.tfplan

  apply-staging:
    name: Apply — Staging
    runs-on: ubuntu-latest
    needs: plan-staging
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Download plan
        uses: actions/download-artifact@v4
        with:
          name: staging-plan
          path: infrastructure/environments/staging

      - name: Terraform Apply — Staging
        working-directory: infrastructure/environments/staging
        run: |
          terraform init
          terraform apply plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }}

  plan-production:
    name: Plan — Production
    runs-on: ubuntu-latest
    needs: apply-staging
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan — Production
        working-directory: infrastructure/environments/production
        run: |
          terraform init
          terraform plan -var-file=terraform.tfvars -out=plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}

      - name: Upload plan artifact
        uses: actions/upload-artifact@v4
        with:
          name: production-plan
          path: infrastructure/environments/production/plan.tfplan

  apply-production:
    name: Apply — Production
    runs-on: ubuntu-latest
    needs: plan-production
    environment: production   # requires mandatory human approval in GitHub Environments
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Download plan
        uses: actions/download-artifact@v4
        with:
          name: production-plan
          path: infrastructure/environments/production

      - name: Terraform Apply — Production
        working-directory: infrastructure/environments/production
        run: |
          terraform init
          terraform apply plan.tfplan
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}
Enter fullscreen mode Exit fullscreen mode

Key pipeline design decisions:

Separate AWS credentials per environment. Dev, staging, and production each use their own IAM credentials, a misconfigured production run cannot accidentally use dev credentials, and vice versa. Scope each credential set to only the AWS resources that environment manages.

Plan before apply, always. Each environment plans first and saves the plan as an artifact. The apply step uses the saved plan, ensuring exactly what was reviewed is what gets applied. A new commit during the pipeline cannot change what production applies.

Production requires human approval. The environment: production GitHub Environment configuration requires designated reviewers to approve before the production apply runs. No automated path to production exists without human sign-off.

Production Safeguards

Layer these protections on your production environment to prevent accidental or unauthorized changes:

Deletion Protection on Critical Resources

# Never allow these to be destroyed via Terraform
resource "aws_db_instance" "production" {
  # ...
  deletion_protection = true
}

resource "aws_s3_bucket" "production_data" {
  # ...
}

resource "aws_s3_bucket_versioning" "production_data" {
  bucket = aws_s3_bucket.production_data.id
  versioning_configuration { status = "Enabled" }
}
Enter fullscreen mode Exit fullscreen mode

Prevent Destroy with Lifecycle Rules

resource "aws_eks_cluster" "production" {
  # ...
  lifecycle {
    prevent_destroy = true   # plan fails if this resource would be destroyed
  }
}
Enter fullscreen mode Exit fullscreen mode

S3 Backend with Object Locking

# Prevent state file deletion or tampering
resource "aws_s3_bucket_object_lock_configuration" "state" {
  bucket = aws_s3_bucket.terraform_state.id
  rule {
    default_retention {
      mode  = "COMPLIANCE"
      days  = 90
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Terraform Sentinel Policies (Terraform Cloud/Enterprise)

For teams on Terraform Cloud or Enterprise, Sentinel policies enforce compliance rules before any plan can apply:

# sentinel/require-tags.sentinel
import "tfplan/v2" as tfplan

required_tags = ["Environment", "Team", "CostCenter", "ManagedBy"]

# Deny any resource missing required tags
deny_resources = filter tfplan.resource_changes as _, rc {
  rc.mode is "managed" and
  rc.change.actions contains "create" and
  any required_tags as tag {
    not rc.change.after.tags[tag] is defined
  }
}

main = rule { length(deny_resources) is 0 }
Enter fullscreen mode Exit fullscreen mode

Module Versioning for Stability

As your module library grows, version-pin module references to prevent unexpected changes when modules are updated:

# environments/production/main.tf - pin module versions
module "rds" {
  source  = "git::https://github.com/your-org/infrastructure.git//modules/rds?ref=v2.3.1"
  # Pinned to v2.3.1, production won't break when v3.0.0 is released
  # ...
}
Enter fullscreen mode Exit fullscreen mode

Or with the Terraform registry:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"   # allows patch updates, blocks major version changes
  # ...
}
Enter fullscreen mode Exit fullscreen mode

Dev and staging can use looser version constraints or ref=main to test new module versions before pinning production to them, the directory structure makes this environment-specific pinning natural.

Common Pitfalls to Avoid

Shared state between environments. A single state file for all environments is not multi-environment Terraform, it is a disaster waiting to happen. Always use separate state backends per environment.

Hardcoded environment names in modules. Modules should accept environment as a variable, not reference it by name. A module that checks var.environment == "production" to enable deletion protection is testing the wrong thing, expose deletion_protection = bool and let the caller decide.

No plan review step for production. Applying Terraform to production without reviewing the plan is the infrastructure equivalent of merging code without reading the diff. The saved plan artifact pattern ensures what is reviewed is what is applied.

Skipping terraform validate and terraform fmt in CI. These run in seconds and catch formatting errors, type mismatches, and undefined variables before plan runs. Add them as the first step of every pipeline job.

Over-engineering with workspaces and directories simultaneously. Using both approaches in the same codebase, workspaces within directory-based environments, creates confusion about where state lives and which isolation mechanism is authoritative. Pick one approach per infrastructure boundary and be consistent.

Conclusion

Multi-environment Terraform is not primarily a technical problem, it is a discipline problem. The tooling makes isolation possible, the structure makes it maintainable, the CI/CD pipeline makes it safe. Directory-based layouts with isolated backends, environment-specific tfvars, separate CI credentials, and a plan-before-apply promotion workflow give you an infrastructure management system you can operate confidently at scale.

The directory-based approach requires more upfront structure than workspaces. It pays that cost back in clarity, every engineer knows exactly which directory controls which environment, exactly where the configuration differences live, and exactly what needs to be reviewed before any change reaches production.

Treat infrastructure environments the same way you treat code environments, isolated, explicitly promoted, never shared, always reviewed before production.

Terraform #InfrastructureAsCode #DevOps #MultiEnvironment #CloudInfrastructure

Top comments (0)