TerraformXV · Environment Architecture and State BoundariesProduction Terraform
Patterns for Multi-Environment Estates
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
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.tfreferences 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.tfvarsfrom 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
Q1. Which pattern is the safest default for production and non-production isolation?
Q2. What is the primary weakness of the workspace-per-env pattern for production isolation?
Q3. What is the role of the branch-per-env pattern?
Q4. A team can safely mix directory-per-env for production and workspace-per-env for sandboxes.
Q5. Which of these are strengths of the directory-per-env pattern? (Select all that apply.)
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.