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"
}
}
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
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
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 }
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
}
# 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
}
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"
}
}
# 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"
}
}
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"
# environments/production/terraform.tfvars
environment = "production"
aws_region = "us-east-1"
app_version = "2.4.1"
alert_email = "pagerduty@your-org.com"
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
# 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
}
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 }}
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" }
}
Prevent Destroy with Lifecycle Rules
resource "aws_eks_cluster" "production" {
# ...
lifecycle {
prevent_destroy = true # plan fails if this resource would be destroyed
}
}
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
}
}
}
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 }
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
# ...
}
Or with the Terraform registry:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # allows patch updates, blocks major version changes
# ...
}
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.
Top comments (0)