Skip to content
intermediate Phase 9 · Workspaces and Environment Strategy

Directory Layouts per Environment

Organizing dev, staging, and prod into separate roots with shared modules.

55m
0 problems
Topic Progress 0%

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.

Referencing Common Modules from Every Root Without Copying Them

Separate roots must not mean separate logic. The mechanism that keeps them honest is the module call: every environment instantiates the same module source and varies only the inputs.

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

  vpc_cidr       = "10.20.0.0/16"
  az_count       = 3
  enable_flow_log = true
}

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

  name        = "payments"
  node_count  = var.node_count
  subnet_ids  = module.network.private_subnet_ids
}

The relative source = "../../modules/network" resolves per root, so dev, staging, and prod all compile the same code from one location; only inputs differ. For published modules, the registry form pins a version per root:

module "vpc" {
  source  = "acme/vpc/aws"
  version = "~> 2.1"   # prod root — staging's root may say ~> 2.4
}

That per-root version argument is the quietly decisive feature: environments may adopt a module release at different times, which is exactly what a shared-configuration workspace setup cannot express. Module versions are recorded in .terraform/modules/modules.json after init, not in .terraform.lock.hcl — the lock file pins providers — so reviewing each root's version arguments is how you review what each environment actually runs.

Source addresses are a per-root decision too. In-repo logic uses relative paths, published modules use the registry with a version, and code shared across repositories but not published anywhere uses a Git source pinned by ref:

module "cluster" {
  source = "git::https://github.com/acme/tf-modules.git//eks?ref=v3.2.0"
}

The ?ref= pin does exactly what version does: without it every root floats to whatever main holds today, and the three environments diverge silently with each merge.

Decide what belongs where with one rule: if two environments need it identically it belongs in a module; if it differs, it becomes an input.

Lives in a root Lives in a module
backend block, required_version Resources and data sources doing real work
Provider blocks and aliases Reusable locals, validations, tags
Module composition and wiring Input/output contract consumed by roots
Environment-only one-offs (envs/prod/waf.tf) Any logic referenced by two roots

If copying the glue between roots still offends you, Terragrunt removes it while keeping the directory model:

# envs/prod/network/terragrunt.hcl
include "root" { path = find_in_parent_folders() }

inputs = merge(
  read_terragrunt_config(find_in_parent_folders("common.hcl")).inputs,
  { node_count = 6 }
)

The root terragrunt.hcl generates the backend and provider blocks from a single template and holds shared inputs in common.hcl, so each unit directory shrinks to an include plus overrides — and terragrunt run-all plan walks the tree. Cross-unit wiring uses dependency blocks instead of hand-written terraform_remote_state blocks, one per unit:

# envs/prod/service/terragrunt.hcl
dependency "network" {
  config_path = "../network"
}

inputs = {
  subnet_ids = dependency.network.outputs.private_subnet_ids
}

Terragrunt resolves the ordering itself on run-all apply, and reading another unit's outputs rather than its raw state keeps the coupling narrow — the same contract rule that governs modules, applied between roots. The trade is real: Terragrunt adds a wrapper layer that every new engineer must learn, so adopt it when the roots genuinely multiply, not preemptively.

A cheap CI check catches the drift this model still allows: diff envs/dev/main.tf against envs/prod/main.tf on every pull request and comment when they differ in more than their variable references. Three copies of wiring drift quietly; that comment makes the drift visible while it is one line old.

The final piece is telling CI which of these directories a change actually affects — covered next.

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 exist error; test -d envs/$ENV first.
  • Branch mapping stays simple. Pull requests run plan and post the diff; pushes to main run apply, 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?

Question 1 options

2. A pull request modifies only modules/network/main.tf. Which roots should the pipeline plan?

Question 2 options

3. A WAF must exist only in production in a directory-per-environment setup. Where should it be declared?

Question 3 options

4. In Terragrunt, what does include "root" { path = find_in_parent_folders() } achieve in an environment unit?

Question 4 options

Flashcards

Question

What makes a directory a root module, and how does -chdir use that?

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?

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?

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?

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?

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.

Continue Learning