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:#fff

Basic 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:#fff

Comparison: 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.tf must be identical across all environments — differences only in terraform.tfvars and backend.tf.
  • The main risk is drift — use regular diff or 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.

← Previous: Workspace   Next: What is Multi Provider? →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact