Directory Based #
The directory-based approach gives each environment its own configuration directory. Each has a completely separate backend, provider, and state. This is a more explicit and easier-to-audit approach than workspaces — you can see directly in the filesystem which configuration belongs to which environment. The trade-off: without good discipline, configurations between environments can slowly diverge.
flowchart TD
A["Directory-based\nEnvironment"] --> B["environments/dev/"]
A --> C["environments/staging/"]
A --> D["environments/prod/"]
B --> E["backend.tf\nterraform.tfvars\nmain.tf"]
C --> F["backend.tf\nterraform.tfvars\nmain.tf"]
D --> G["backend.tf\nterraform.tfvars\nmain.tf"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#ef4444,stroke:#dc2626,color:#fffBasic Structure #
environments/
├── dev/
│ ├── main.tf ← Call modules + dev-specific resources
│ ├── variables.tf ← Variable declarations
│ ├── terraform.tfvars ← Variable values for dev
│ ├── outputs.tf
│ └── backend.tf ← Backend config for dev
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars ← Variable values for staging
│ ├── outputs.tf
│ └── backend.tf ← Backend config for staging
└── production/
├── main.tf
├── variables.tf
├── terraform.tfvars ← Variable values for production
├── outputs.tf
└── backend.tf ← Backend config for production
# How it works: enter the environment directory, then terraform apply
cd environments/production
terraform init
terraform apply -var-file="terraform.tfvars"
# No risk of "forgetting the active workspace" because the directory is clear
Avoiding Duplication with Modules #
The main challenge of directory-based is consistency — three directories means three places that can diverge. The solution is moving all logic into modules and making the environment directories as thin as possible.
# environments/production/main.tf — as thin as possible
# Only configuration that truly differs per environment
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
provider "aws" {
region = var.aws_region
assume_role {
role_arn = "arn:aws:iam::${var.account_id}:role/TerraformDeployRole"
}
}
# The same module is called in all environments
module "networking" {
source = "../../modules/networking"
environment = var.environment
vpc_cidr = var.vpc_cidr
public_subnet_count = var.public_subnet_count
private_subnet_count = var.private_subnet_count
}
module "compute" {
source = "../../modules/compute"
environment = var.environment
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_type = var.instance_type
instance_count = var.instance_count
}
module "database" {
source = "../../modules/database"
environment = var.environment
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_class = var.db_instance_class
multi_az = var.db_multi_az
backup_retention = var.db_backup_retention
}
# environments/production/terraform.tfvars
environment = "production"
aws_region = "ap-southeast-1"
account_id = "333333333"
vpc_cidr = "10.2.0.0/16"
public_subnet_count = 3
private_subnet_count = 3
instance_type = "t3.medium"
instance_count = 3
db_instance_class = "db.r5.large"
db_multi_az = true
db_backup_retention = 30
# environments/dev/terraform.tfvars — IDENTICAL structure, different values
environment = "dev"
aws_region = "ap-southeast-1"
account_id = "111111111"
vpc_cidr = "10.0.0.0/16"
public_subnet_count = 1
private_subnet_count = 1
instance_type = "t3.micro"
instance_count = 1
db_instance_class = "db.t3.micro"
db_multi_az = false
db_backup_retention = 0
Complete Project Structure #
infrastructure/
├── modules/ ← Shared modules (single source of truth)
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── compute/
│ │ └── ...
│ └── database/
│ └── ...
│
└── environments/
├── dev/
│ ├── main.tf ← All call the same modules
│ ├── variables.tf ← Variable declarations (identical in all envs)
│ ├── outputs.tf ← Outputs (identical in all envs)
│ ├── backend.tf ← Dev backend config
│ └── terraform.tfvars ← Values for dev
├── staging/
│ ├── main.tf ← Identical to dev/main.tf
│ ├── variables.tf ← Identical to dev/variables.tf
│ ├── outputs.tf ← Identical to dev/outputs.tf
│ ├── backend.tf ← Staging backend config
│ └── terraform.tfvars ← Values for staging
└── production/
├── main.tf ← Identical to dev/main.tf
├── variables.tf ← Identical to dev/variables.tf
├── outputs.tf ← Identical to dev/outputs.tf
├── backend.tf ← Production backend config
└── terraform.tfvars ← Values for production
Note that main.tf, variables.tf, and outputs.tf in every environment are identical to each other — the only differences are in terraform.tfvars and backend.tf.
Keeping Environments Consistent #
The biggest risk of directory-based is drift — when someone adds a resource to production/main.tf but forgets to add it to dev/main.tf and staging/main.tf.
# Method 1: Diff between environments to detect differences
diff environments/dev/main.tf environments/production/main.tf
diff environments/dev/variables.tf environments/production/variables.tf
# If there's an unintended difference, fix it immediately
# Method 2: Use symlinks for files that must be identical
# (not common but can be considered)
cd environments/staging
ln -sf ../dev/main.tf main.tf
ln -sf ../dev/variables.tf variables.tf
ln -sf ../dev/outputs.tf outputs.tf
# Only terraform.tfvars and backend.tf differ
# Method 3: Create a script that checks consistency in CI
# (detect if main.tf in one env differs from the others)
# Method 4: Move ALL logic into modules
# If environments/*/main.tf only contains module calls,
# there's no logic that can diverge there
# environments/dev/main.tf:
module "app" {
source = "../../modules/app"
# variable values from tfvars
}
# environments/production/main.tf:
module "app" {
source = "../../modules/app"
# variable values from tfvars
}
# Both files are identical — the only difference is in tfvars
# → No risk of logic drift
flowchart TD
A["environments/dev/"] -->|"source"| B["modules/"]
C["environments/staging/"] -->|"source"| B
D["environments/prod/"] -->|"source"| B
B --> E["shared modules\nvpc, eks, rds"]
style A fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#ef4444,stroke:#dc2626,color:#fff
style B fill:#3b82f6,stroke:#1e40af,color:#fffComparison: Workspace vs Directory Based #
WORKSPACE DIRECTORY BASED
──────────────────────────────────────────────────────────
State isolation ✓ (separate) ✓ (fully separate)
Account isolation ✗ (not possible) ✓ (different providers)
Access isolation ✗ (same for all) ✓ (per directory)
Setup ease ✓ (faster) ✗ (more files)
Wrong-env risk High Low (clear directories)
Multi-region ✗ (difficult) ✓ (different provider configs)
Best for Ephemeral / dev Staging & production
Directory-Based State Management #
# Each environment directory has its own backend.tf
# environments/dev/backend.tf
terraform {
backend "s3" {
bucket = "terraform-state-dev"
key = "infrastructure/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-lock-dev"
encrypt = true
}
}
# environments/staging/backend.tf
terraform {
backend "s3" {
bucket = "terraform-state-staging"
key = "infrastructure/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-lock-staging"
encrypt = true
}
}
# environments/production/backend.tf
terraform {
backend "s3" {
bucket = "terraform-state-production"
key = "infrastructure/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-lock-production"
encrypt = true
}
}
# Deploy a specific environment
cd environments/staging
terraform init
terraform plan -var-file="terraform.tfvars"
terraform apply -var-file="terraform.tfvars"
# Deploy all environments (script)
#!/bin/bash
for env in dev staging production; do
echo "Deploying $env..."
cd "environments/$env"
terraform init
terraform apply -auto-approve
cd ../..
done
Cross-Environment References #
# Environments often need to reference each other
# DEV references networking from SHARED
# environments/shared/main.tf
output "vpc_id" {
value = module.networking.vpc_id
}
output "dns_zone_id" {
value = aws_route53_zone.main.zone_id
}
# environments/dev/main.tf
data "terraform_remote_state" "shared" {
backend = "s3"
config = {
bucket = "terraform-state-shared"
key = "terraform.tfstate"
region = "ap-southeast-1"
}
}
resource "aws_instance" "web" {
subnet_id = data.terraform_remote_state.shared.outputs.private_subnet_ids[0]
}
Environment Variable Files #
# Each environment has different variable files
# environments/dev/terraform.tfvars
instance_type = "t3.micro"
db_instance_class = "db.t3.micro"
enable_monitoring = false
backup_retention = 1
multi_az = false
environment = "dev"
# environments/production/terraform.tfvars
instance_type = "t3.xlarge"
db_instance_class = "db.r5.large"
enable_monitoring = true
backup_retention = 30
multi_az = true
environment = "production"
# Shared modules across environments
# modules/infrastructure/main.tf is referenced from all envs
# Validate consistency across environments
for env in dev staging production; do
echo "=== $env ==="
cd environments/$env
terraform validate
cd ../..
done
Summary #
- Directory-based gives full isolation — separate backends, separate providers, separate accounts per environment.
- Modules are the key to consistency — move all logic into modules, make environment directories as thin as possible (only tfvars and backend).
main.tf,variables.tf,outputs.tfmust be identical across all environments — differences only interraform.tfvarsandbackend.tf.- The main risk is drift — use regular
diffor symlinks to make sure files that should be identical don’t diverge.- Safer than workspaces for production — no risk of “forgetting the active workspace”, access can be controlled per directory.
- Choose directory-based when you need multi-account, multi-region, or per-environment access isolation. Choose workspaces for ephemeral environments.