Splitting dev, staging, and prod into Independent Roots
Workspace Design Tradeoffs ended on the objection every senior engineer raises: separate directories mean copying the same wiring three times. The layout below answers it by copying wiring only and never logic. A root module is any directory containing the configuration Terraform treats as the top of the graph: its own init, its own backend, its own state. The directory-per-environment model is simply that definition applied literally — three roots, three states, no cleverness:
infra/
├── modules/
│ ├── network/
│ └── service/
└── envs/
├── dev/
│ ├── backend.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── dev.tfvars
├── staging/
│ └── …same five files, staging.tfvars
└── prod/
└── …same five files, prod.tfvars
Each root is initialised and operated independently, and Terraform 1.5+ lets one working directory drive them all with -chdir:
terraform -chdir=envs/prod init
terraform -chdir=envs/prod plan -var-file=prod.tfvars -out=tfplan
terraform -chdir=envs/prod apply tfplan
-chdir changes the directory every relative path resolves against, so prod.tfvars means envs/prod/prod.tfvars, and each root keeps its own .terraform/ working area, provider plugins, and module cache.
Separate state, separate backend, separate blast radius. The backend block differs per root — and since backend arguments must be literals anyway, there is no duplication to feel guilty about:
# envs/prod/backend.tf
terraform {
required_version = "~> 1.9"
backend "s3" {
bucket = "acme-tfstate-prod"
key = "services/payments/terraform.tfstate"
region = "eu-west-1"
encrypt = true
}
}
Pointing production at acme-tfstate-prod and dev at acme-tfstate-dev is the difference between a staging developer holding state-plane credentials for everything and holding them for staging. The lock lives with the state, so the three roots apply concurrently without contending.
Variables are where the model pays off daily. Each root declares the same variable blocks — the contract is shared — while the values live in that root's .tfvars file and load automatically:
# envs/dev/dev.tfvars
account_id = "111122223333"
region = "eu-west-1"
node_count = 1
domain = "payments.dev.example.com"
# envs/prod/prod.tfvars
account_id = "444455556666"
region = "eu-west-1"
node_count = 6
domain = "payments.example.com"
No -var-file plumbing to remember, no terraform.tfvars shared by accident: open the directory and the environment's identity is visible in the file listing.
If you would rather keep account numbers out of the files entirely, use partial configuration and supply the literals at init:
# envs/prod/backend.tf
terraform {
backend "s3" {} # no arguments — filled in per environment
}
terraform -chdir=envs/prod init \
-backend-config="bucket=acme-tfstate-prod" \
-backend-config="key=services/payments/terraform.tfstate"
Literal backend files are greppable and reviewed alongside the rest of the environment; partial config keeps sensitive values out of a shared repository at the cost of an init command CI must get right on every run. Either is defensible — choosing deliberately is not optional.
Outputs follow the same discipline: one outputs.tf per root, publishing that environment's URL, certificate ARN, and queue names, so the contract stays identical while the values necessarily differ.
The cost of the model is wiring drift. Three copies of main.tf mean a new output added to dev but forgotten in prod is a silent divergence that only shows up when someone debugs production. Keep roots deliberately thin — backend, provider block, module calls, outputs — push all logic into modules/, and add cheap CI checks: terraform fmt -check -recursive, plus a diff of the roots themselves so a pull request that edits only envs/dev/main.tf raises a review comment. If the roots start growing past a screen, that is the signal logic leaked out of the modules.
Mapping Branches and Changed Paths to Directories in CI
The layout only pays for itself once the pipeline can answer "which roots did this commit touch?" without running three full plans every time. Two answers combine: a path filter that maps changed files to directories, and a matrix that runs Terraform only inside the selected ones.
jobs:
detect:
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.changes.outputs.changes }}
steps:
- uses: actions/checkout@v4
- id: changes
uses: dorny/paths-filter@v3
with:
filters: |
dev: ['envs/dev/**', 'modules/**']
staging: ['envs/staging/**', 'modules/**']
prod: ['envs/prod/**', 'modules/**']
plan:
needs: detect
runs-on: ubuntu-latest
strategy:
matrix:
env: [dev, staging, prod]
if: ${{ contains(needs.detect.outputs.envs, matrix.env) }}
steps:
- uses: actions/checkout@v4
- run: terraform -chdir=envs/${{ matrix.env }} init -input=false
- run: terraform -chdir=envs/${{ matrix.env }} plan -input=false
-var-file=${{ matrix.env }}.tfvars -out=tfplan
- run: terraform -chdir=envs/${{ matrix.env }} show -json tfplan > tfplan.json
- uses: actions/upload-artifact@v4
with:
name: plan-${{ matrix.env }}
path: tfplan*
Read the filter rules carefully, because they encode the blast radius of every path: a change under envs/prod/** plans only production, while a change under modules/** plans all three — shared code can reach any root, so it must be exercised everywhere. Merge a pull request that only renames a dev tag and production's job never runs, never touches state, and never risks a lock.
No filter action? The same mapping comes from Git directly — note the three-dot range, which diffs against the merge base rather than the previous commit:
BASE="origin/${{ github.base_ref || 'main' }}"
CHANGED=$(git diff --name-only --diff-filter=ACMR "$BASE"...HEAD)
for env in dev staging prod; do
printf '%s\n' "$CHANGED" | grep -q "^envs/$env/\|^modules/" && echo "$env" >> affected.txt
done
The apply side reuses the same matrix, gated on the branch:
apply:
needs: [plan]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: ${{ matrix.env }} # production entry carries required reviewers
steps:
- run: terraform -chdir=envs/${{ matrix.env }} apply -input=false -auto-approve tfplan
Three operational details decide whether this stays reliable:
- Cache per directory.
.terraform/already lives inside each root thanks to-chdir, but the actions cache key must include the root path and.terraform.lock.hcl(envs-${{ matrix.env }}-${{ hashFiles(format('envs/{0}/.terraform.lock.hcl', matrix.env)) }}), otherwise one environment's provider cache is restored into another's. - Validate the directory before init. A matrix entry for a deleted environment fails with a confusing
Configuration does not existerror;test -d envs/$ENVfirst. - Branch mapping stays simple. Pull requests run
planand post the diff; pushes tomainrunapply, still scoped by the same filter. Do not add a third mapping from branch names to environments — the directory is already the environment's name.
Values that cannot sit in a committed .tfvars — database passwords, API tokens — arrive as TF_VAR_ environment variables injected per matrix entry, so the secret is attached to the environment rather than to a file. The directory names the tier and CI supplies that tier's secrets; nothing in the repository has to know which environment it is serving.
Because each root owns its state, plan jobs for different roots run concurrently without contending — each holds a different lock object — while two runs against the same root queue normally, which is exactly the behaviour you want: parallel where isolation is real, serial where it is not. Only the roots a commit touched participate at all, so unrelated work never pays for Terraform's runtime.
That scoped, per-directory behaviour is precisely what a promotion pipeline needs as input, and Promoting Changes Across Environments turns it into plan-on-PR, apply-on-merge.
Quiz
1. You run terraform -chdir=envs/prod plan -var-file=prod.tfvars. Which file is read?
2. A pull request modifies only modules/network/main.tf. Which roots should the pipeline plan?
3. A WAF must exist only in production in a directory-per-environment setup. Where should it be declared?
4. In Terragrunt, what does include "root" { path = find_in_parent_folders() } achieve in an environment unit?
Flashcards
Question
What makes a directory a root module, and how does -chdir use that?
Click to reveal answer
Answer
A root module is the directory Terraform treats as the top of the graph: it runs its own init, owns its own backend, state, lock, and working area. terraform -chdir=envs/prod init|plan|apply targets that root without a cd, resolving every relative path inside it.
Question
Where do per-environment account ids, regions, and capacities live in a directory layout?
Click to reveal answer
Answer
In a tfvars file inside that environment's root — envs/prod/prod.tfvars — which loads automatically, while the shared contract lives in variables.tf. The same variable blocks appear in every root; only the values differ, and nothing is passed on the command line.
Question
What is the rule for deciding what lives in a root versus a module?
Click to reveal answer
Answer
If two environments need it identically, it belongs in a module; if it differs, it becomes an input. Roots hold backend, providers, module composition, and environment-only one-offs; modules hold reusable logic and the input/output contract.
Question
How does CI decide which roots to plan after a commit?
Click to reveal answer
Answer
A path filter maps changed files to directories: envs/prod/** triggers only prod, modules/** triggers every root. The matrix job then runs terraform -chdir only for the selected roots, and cache keys must include the root path plus its .terraform.lock.hcl.
Question
How does Terragrunt remove duplicated glue between environment directories?
Click to reveal answer
Answer
include "root" { path = find_in_parent_folders() } inherits a single root config that generates backend and provider blocks and reads shared inputs from common.hcl, so each unit directory is a handful of lines; dependency blocks with outputs wire units together instead of terraform_remote_state.
Revision Notes
Key Takeaways
- 1. Each environment directory is an independent root module: its own init, backend, state, lock, and automatically loaded tfvars file
- 2. terraform -chdir targets a root without changing directories, so pipelines operate envs/prod with plain commands and per-root .terraform areas
- 3. Roots stay thin — backend, providers, module calls, outputs — while shared logic lives in modules; identical needs go in a module, differing values become inputs
- 4. Module versions pin per root, so staging can adopt a release before production — the deliberate version lag workspaces cannot express
- 5. CI path filters make directories the unit of blast radius: envs/prod/** plans only production, modules/** plans everything, and unchanged roots are never touched
Interview Tips
- • 'Show me how you structure environments in one repository.' — draw envs/{dev,staging,prod} roots plus modules/, state that each root owns backend and tfvars, and note terraform -chdir operates them without cd
- • 'How do you avoid duplicating code across environments?' — logic in shared modules, glue generated by Terragrunt if roots multiply; duplicated wiring is cheap, duplicated logic is the smell
- • 'A module change must reach all environments but a dev-only change must not touch prod. How?' — path filters: modules/** plans every root, envs/dev/** plans only dev; directory boundaries make the second case structurally safe
- • 'How do you let staging test a new module version before production?' — pin version per root: staging's module block says ~> 2.4, prod's says ~> 2.1; module versions live in each root's config, not in the provider lock file
Cheat Sheet
Directory Layouts per Environment
Layout:
infra/modules/{network,service} # one copy of the logic
infra/envs/dev|staging|prod/ # thin roots: backend, variables, wiring
backend.tf main.tf variables.tf outputs.tf <env>.tfvars
Operate a root without cd:
terraform -chdir=envs/prod init
terraform -chdir=envs/prod plan -var-file=prod.tfvars -out=tfplan
terraform -chdir=envs/prod apply tfplan
Per-root backend = per-env state blast radius:
backend "s3" {
bucket = "acme-tfstate-prod" # separate bucket per environment
key = "services/payments/terraform.tfstate"
}
Splitting responsibility:
| Root holds | Module holds |
|---|---|
| backend, providers, aliases | resources and data sources |
| module composition + env one-offs | reusable logic, validations |
version pin for registry modules |
input/output contract |
CI mapping: dorny/paths-filter → matrix over [dev, staging, prod], if: contains(changes, matrix.env); modules/** triggers every root, envs/prod/** triggers only prod. Cache keys must include the root path + .terraform.lock.hcl.
Terragrunt glue: include "root" { path = find_in_parent_folders() } + inputs = merge(read_terragrunt_config(find_in_parent_folders("common.hcl")).inputs, {...}); cross-unit wiring via dependency blocks.