Skip to main content
RunBook Academy

TerraformXV · Environment Architecture and State BoundariesProduction Terraform

Patterns for Multi-Environment Estates

Intermediate⏱ ~14 minbash

What you'll learn

  • Compare the directory-per-env, workspace-per-env, and branch-per-env patterns
  • Choose the right pattern for a given team size and risk profile
  • Identify the credential model that each pattern implies
  • Recognise the production risks of mixing patterns

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A team that needs multiple environments must pick a pattern. Three patterns cover most production estates. Each pattern implies a credential model, a state layout, and a CI/CD shape. The lesson teaches what each pattern is, what each costs, and when each is right.

The three patterns

The three patterns for running multiple environments with the same Terraform code:

directory-per-env     one root, multiple working directories,
                      one backend per directory, separate
                      credentials per directory

workspace-per-env     one root, one working directory,
                      one backend with multiple named
                      workspaces, shared credentials

branch-per-env        one root per environment (or one root,
                      branches selected at run time),
                      one backend per branch (typically),
                      per-branch credentials

The patterns differ in three dimensions: how the state is separated, how the credentials are separated, and how the code is shared between environments.

Pattern 1: directory-per-env

The directory-per-env pattern is the most explicit. Each environment has its own directory, its own backend configuration, and its own credentials:

infra/
├── modules/
│   ├── network/
│   └── compute/
├── envs/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   ├── dev.tfvars
│   │   └── provider.tf
│   ├── staging/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   ├── staging.tfvars
│   │   └── provider.tf
│   └── prod/
│       ├── main.tf
│       ├── backend.tf
│       ├── prod.tfvars
│       └── provider.tf

Each envs/<name> directory is a Terraform root with its own state. The modules/ directory is shared via a relative source:

# envs/prod/main.tf
module "network" {
  source = "../../modules/network"

  cidr_block = "10.0.0.0/16"
  env_name   = "prod"
}

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

  instance_type = "m5.large"
  env_name      = "prod"
}

The backend is per-directory:

# envs/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The credentials are per-directory. The CI/CD pipeline selects the directory based on the target environment:

cd envs/prod
terraform init
terraform plan -var-file=prod.tfvars -out=prod.tfplan
terraform apply prod.tfplan

Strengths. Each environment is a separate Terraform root. A plan against production cannot accidentally affect staging. The state is per-environment. The credentials are per-environment. The pattern is explicit and reviewable.

Weaknesses. Module duplication is awkward. Refactoring a shared module requires updating every directory. The pattern scales by adding more directories, which scales the maintenance cost linearly with the number of environments.

Right for. Production estates with three or more environments where the team is comfortable with the operational overhead of per-environment directories.

Pattern 2: workspace-per-env

The workspace-per-env pattern uses Terraform’s built-in workspaces to share one backend configuration across multiple environments:

# envs/main.tf - one root, one backend block
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = "us-east-1"

  assume_role {
    role_arn = local.workspace_role_map[terraform.workspace]
  }
}

The workspaces are dev, staging, and prod:

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod

The variables change based on the workspace:

locals {
  config = {
    dev = {
      cidr_block   = "10.10.0.0/16"
      instance_type = "t3.medium"
    }
    staging = {
      cidr_block   = "10.20.0.0/16"
      instance_type = "m5.large"
    }
    prod = {
      cidr_block   = "10.30.0.0/16"
      instance_type = "m5.xlarge"
    }
  }
  workspace_config = local.config[terraform.workspace]
}

The state is stored under one key per workspace (e.g. env:dev, env:prod). The credentials are resolved at apply time via the assume_role block.

Strengths. One backend configuration, one provider block, one module set. The pattern is compact.

Weaknesses. Workspaces share the backend bucket, the lock table, and the IAM principal. A bad actor with prod workspace access also has access to the same bucket as dev. A workspace select prod mistake is one letter away from disaster.

Right for. Short-lived environments (per-engineer sandboxes, feature branches) that share the same credential model. Not for production isolation.

Pattern 3: branch-per-env

The branch-per-env pattern uses Git branches to represent environments. The branch is the environment:

main       →  production
staging    →  staging environment
dev        →  dev environment

Each branch has its own backend and credentials. The CI/CD pipeline selects the backend based on the branch:

# .github/workflows/terraform.yml
name: Terraform
on:
  push:
    branches: [main, staging, dev]

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars[github.ref_name] }}
          aws-region: us-east-1
      - run: terraform init -backend-config=envs/${{ github.ref_name }}.tfbackend
      - run: terraform plan -var-file=${{ github.ref_name }}.tfvars

Strengths. Branch protection rules double as environment protection. A PR to main requires the production reviewers. The pattern is natural for teams that already use trunk-based development.

Weaknesses. Branches drift. The code in staging and main may differ by more than the variable values. A long-lived branch is a maintenance burden.

Right for. Trunk-based teams that want branch protection as their primary review mechanism.

How to choose

A decision tree:

How many environments?
  ├─ 2 or fewer  → directory-per-env
  ├─ 3 or more, with a few engineers
  │   → directory-per-env
  ├─ Many short-lived sandboxes for one root
  │   → workspaces for the sandboxes,
  │     directory-per-env for prod/nonprod
  └─ Trunk-based team with branch protection
      → branch-per-env

The safest default is directory-per-env for production and non-production, with workspaces for short-lived sandboxes on top of the dev directory.

Mixing patterns

The patterns can be combined, but the combination must be deliberate. A team that runs directory-per-env for prod and staging but workspace-per-env for dev sandboxes is making a deliberate choice: production and staging have separate state backends and credentials; dev sandboxes share a backend with each other but not with production.

A team that runs workspace-per-env for staging and production because the directory-per-env layout was “too much work” has chosen convenience over isolation. The blast radius is now the shared backend.

What comes next

The next lesson is environment directories with separate backends: the directory-per-env pattern in detail, with concrete configuration and CI/CD wiring.

Verification

  • ls envs/ lists one directory per environment.
  • Each envs/<name>/backend.tf references a different state key (e.g. prod/terraform.tfstate, staging/terraform.tfstate).
  • terraform workspace list (if used) shows the active workspaces and confirms the current selection matches the intent.
  • terraform plan -var-file=prod.tfvars from the prod directory shows prod resources; the same command from the staging directory shows staging resources.
  • The IAM role used by the prod directory cannot read the state bucket used by staging.

Knowledge check · 6 questions

  1. Q1. Which pattern is the safest default for production and non-production isolation?

  2. Q2. What is the primary weakness of the workspace-per-env pattern for production isolation?

  3. Q3. What is the role of the branch-per-env pattern?

  4. Q4. A team can safely mix directory-per-env for production and workspace-per-env for sandboxes.

  5. Q5. Which of these are strengths of the directory-per-env pattern? (Select all that apply.)

  6. Q6. A team is starting a green-field project. They want to use Terraform from day one. Which pattern is the safest default for production and non-production?

Passing score: 75%. Answers are checked in this browser.