Skip to main content
RunBook Academy

TerraformXXVII · Enterprise Scale: Multi-Team, Multi-AccountProduction Terraform

The Repository Architecture

Advanced⏱ ~12 minbash

What you'll learn

  • Distinguish state repositories from module repositories
  • Choose the right repo layout for a given team size and ownership model
  • Design the CI layout per state and per module
  • Configure module pipelines to validate consumers
  • Identify the failure modes of a misaligned repo architecture

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.

The repository strategy is the seam between Terraform code and the teams that own it. Get it right and the CI layout, the CODEOWNERS rules, and the change windows all fall out naturally. Get it wrong and every change is a coordination exercise. The lesson is about choosing the seams deliberately.

What a “repository architecture” actually decides

A Terraform estate has two kinds of code:

  • Stacks — root modules that own state. A stack is terraform init && terraform apply’d against a real environment. Each stack is a state boundary.
  • Modules — child modules that are source’d into stacks. A module is not applied directly; it is consumed by stacks. Each module is a versioning boundary.

The repository architecture decides where stacks and modules live, how they are versioned, and how changes flow between them. The decisions are:

  ┌─────────────────────────────────────────────────────────┐
  │  1. Are stacks in one repo (monorepo) or many repos     │
  │     (polyrepo)?                                         │
  │                                                         │
  │  2. Are modules in the same repo as stacks, a separate  │
  │     repo, or several separate repos?                    │
  │                                                         │
  │  3. How are modules versioned (git tags, semver,        │
  │     registry tags)?                                     │
  │                                                         │
  │  4. What does the CI layout look like for stacks        │
  │     versus modules?                                     │
  │                                                         │
  │  5. How does a module change flow to its consumers?     │
  └─────────────────────────────────────────────────────────┘

Layout A: one repo per stack, one repo per module

The polyrepo layout. Every stack has its own repository; every module has its own repository. A typical small estate:

  org/
  ├── terraform-module-network-vpc/        # module repo
  ├── terraform-module-eks/                # module repo
  ├── terraform-module-cloudwatch-alarms/  # module repo
  │
  ├── terraform-stack-prod-eu-network/     # stack repo
  ├── terraform-stack-prod-eu-compute/     # stack repo
  ├── terraform-stack-prod-us-network/     # stack repo
  ├── terraform-stack-nonprod-eu-dev/      # stack repo
  └── terraform-stack-nonprod-eu-staging/  # stack repo

Each repo has its own CI pipeline, its own CODEOWNERS, its own release cadence. The module repos publish tagged versions; the stack repos pin to specific tags.

When this wins. A small team (fewer than ten stacks) with independent stacks and a clean module set. The boundaries are obvious and the CI is straightforward.

When this loses. A medium team with frequent cross-stack changes. A module update that touches three stacks is three PRs in three repos with three CI pipelines. The CI latency alone starts to dominate the change window.

Layout B: monorepo for stacks, polyrepo for modules

The most common hybrid at the time of writing. Stacks live in one repo with stacks/<name>/ directories; modules live in separate repos with their own versioning.

  org/
  ├── terraform-mono/                      # stack monorepo
  │   ├── stacks/
  │   │   ├── prod-eu-network/
  │   │   ├── prod-eu-compute/
  │   │   └── ...
  │   └── .github/CODEOWNERS
  │
  ├── terraform-module-network-vpc/        # module repo
  ├── terraform-module-eks/                # module repo
  └── terraform-module-cloudwatch-alarms/  # module repo

Stacks consume modules via a private registry (covered in the next lesson) or via git::https://...//...?ref=vX.Y.Z in the source argument.

When this wins. A team with frequent cross-stack changes (typical at 10+ stacks) and a small module set (typical at 5–20 internal modules). The cross-stack change is one PR in the monorepo; the module update is one tag in the module repo.

When this loses. A team with a large module set (50+ internal modules) and a small stack set. The module repos proliferate; discoverability collapses.

Layout C: full monorepo

Stacks and modules in one repo. The previous lesson covers this in detail.

  org/terraform-mono/
  ├── modules/
  │   ├── network/vpc/
  │   ├── compute/eks/
  │   └── observability/cloudwatch-alarms/
  ├── stacks/
  │   ├── prod-eu-network/
  │   └── ...
  └── .github/
      ├── CODEOWNERS
      └── workflows/

When this wins. A large team with a large module set and frequent cross-stack changes. One repo to find every Terraform code path in the organisation; one CI configuration to maintain; one CODEOWNERS to enforce.

When this loses. A small team. The overhead is not worth it.

The CI layout per repo type

The CI layout differs by repo type. A misaligned CI layout is the most common reason a “right” repo architecture still produces wrong outcomes.

Per-stack CI. Runs on PRs to the stack’s repo (or the stack’s directory in a monorepo).

# .github/workflows/plan.yml — runs on every PR to the stack
name: plan
on:
  pull_request:
    branches: [ main ]

jobs:
  plan:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8
      - name: terraform fmt
        run: terraform fmt -check -recursive
      - name: terraform init
        run: terraform init -backend=false
      - name: terraform validate
        run: terraform validate -no-color
      - name: terraform plan
        run: terraform plan -no-color -out=tfplan
        env:
          AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
      - name: terraform show
        if: always()
        run: terraform show -no-color tfplan

The plan is the output. The PR shows the plan; the reviewer approves; the merge triggers an apply job that is gated on the reviewer list.

Per-module CI. Runs on PRs to the module. The shape is different: the module is tested as a unit, then integration tests run terraform plan against each consumer stack.

# .github/workflows/test.yml — runs on every PR to the module
name: test
on:
  pull_request:
    branches: [ main ]

jobs:
  unit:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - name: terraform init (example)
        working-directory: examples/simple
        run: terraform init
      - name: terraform validate (example)
        working-directory: examples/simple
        run: terraform validate -no-color
      - name: terraform plan (example)
        working-directory: examples/simple
        run: terraform plan -no-color
# .github/workflows/integration.yml — runs consumers against the PR
name: integration
on:
  pull_request:
    branches: [ main ]

jobs:
  consumers:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        consumer: [ prod-eu-network, prod-eu-compute, nonprod-eu-dev ]
    steps:
      - uses: actions/checkout@v4
        with:
          repository: org/terraform-mono
      - uses: hashicorp/setup-terraform@v3
      - name: terraform init (consumer)
        working-directory: stacks/${{ matrix.consumer }}
        run: |
          terraform init \
            -backend=false \
            -from-module=../../modules/network/vpc?ref=${{ github.event.pull_request.head.sha }}
      - name: terraform plan (consumer)
        working-directory: stacks/${{ matrix.consumer }}
        run: terraform plan -no-color

The integration job checks out the consumer stacks, points the source at the PR’s branch, and runs terraform plan. If the PR breaks a consumer, the PR fails before it merges.

Versioning modules

Modules are versioned with semver. The rules:

  MAJOR bump   Incompatible change to variables or outputs.
               Consumers must update their code to upgrade.
               Examples: rename a variable, remove an output,
               change a default that affects existing
               consumers.

  MINOR bump   Backwards-compatible feature addition.
               Consumers can upgrade by changing the version
               pin and re-running plan. Examples: add a new
               optional variable, add a new output.

  PATCH bump   Backwards-compatible bug fix.
               Consumers can upgrade without changes.
               Examples: fix a tag, correct a default that
               does not match the documentation.

A consumer pins to a specific version:

# stacks/prod-eu-network/main.tf
module "vpc" {
  source  = "git::https://github.com/org/terraform-module-network-vpc.git//vpc?ref=v3.2.1"
  version = "~> 3.2"

  cidr_block = "10.0.0.0/16"
  # ...
}

The ref=v3.2.1 pins the source to a specific tag; the version = "~> 3.2" constraint in the Terraform Registry (or ?ref=v3.2 in the git source) limits upgrades to patch versions within the minor.

The discipline: never consume main. A consumer that pulls from main will pick up unreleased changes; the next terraform init will produce a different plan; the audit trail is lost. Pin the source to a tag.

How to validate the repo architecture

# READ-ONLY: every module source is pinned to a specific tag.
grep -rn 'source.*=.*github.com/org/terraform-module' stacks \
  --include='*.tf' | grep -v '?ref=v'
stacks/prod-eu-network/main.tf:21:    source = "git::https://github.com/org/terraform-module-network-vpc.git//vpc"

Any module source without a ?ref=vX.Y.Z argument is a bug. The output should be empty.

# READ-ONLY: every stack has its own backend key.
find stacks -name 'backend.tf' -exec grep -H 'key' {} \;
stacks/prod-eu-network/backend.tf:    key = "stacks/prod-eu-network/terraform.tfstate"
stacks/prod-eu-compute/backend.tf:    key = "stacks/prod-eu-compute/terraform.tfstate"

Every key is unique. If two keys match, the boundary is broken.

# READ-ONLY: every module repo has a release tag.
gh release list --repo org/terraform-module-network-vpc \
  --limit 5 --json tagName
[
  "v3.2.1",
  "v3.2.0",
  "v3.1.0",
  "v3.0.0",
  "v2.4.3"
]

Tags exist and follow semver. The list shows major, minor, and patch releases that consumers can pin to.

Production failure modes

1. Module consumer pulls from main. Symptom: a terraform init produces a different plan without any code change. Cause: a module source that references main instead of a tag. Recovery: pin the source to a tag; audit the recent plan diffs to see what changed; document the pin in the contribution guide.

2. Module repo has no CI. Symptom: a module PR merges without testing; the next consumer plan is broken. Cause: the module repo was bootstrapped without CI. Recovery: add the unit and integration workflows; require CI to pass before merge.

3. Stack repo consumes a module repo without integration testing. Symptom: a module upgrade breaks three consumers silently. Cause: the stack repo’s CI does not run against the module’s PR branch. Recovery: add the integration workflow that checks out the consumer and points the source at the PR’s SHA.

4. Module is published before it is tested. Symptom: a new module version is released; a consumer upgrades; the plan is broken. Cause: the release pipeline does not gate on integration test success. Recovery: require the integration job to pass before tagging a new version; tag the release from CI, not from a developer’s laptop.

5. Repo architecture does not match team boundaries. Symptom: a team cannot ship a change because another team owns the repo. Cause: the repo was created for a different team model; the team model changed; the repo did not. Recovery: re-evaluate the repo architecture against the current org chart; either move the stack to a new repo or add cross-team CODEOWNERS to the existing repo.

6. Polyrepo state bucket sprawl. Symptom: 30 state buckets, each with its own lock table, each with its own lifecycle policy. Cause: every stack is in its own repo, and every repo bootstrapped its own backend. Recovery: move the state buckets to a single shared services account; use per-stack keys in a per-environment bucket.

7. Monorepo used for stacks, polyrepo used for modules, but no source pinning. Symptom: a module upgrade that changes the public surface area breaks a downstream stack. Cause: the stack consumes ?ref=main. Recovery: pin to a tag; document the pin; add a CI check that fails on unpinned sources.

Security implications

  • The module repo’s CODEOWNERS file is the trust anchor for the module’s consumers. A module change can affect every consumer; the review must be required, not optional.
  • The stack repo’s secrets are the trust anchor for the stack’s CI. The CI must use OIDC federation (covered in earlier lessons) and must not store long-lived AWS access keys.
  • The module registry (covered in the next lesson) is the supply-chain boundary. Public modules should not be used in production without review; internal modules should be published to the private registry, not the public one.

Performance implications

  • Per-stack CI is O(stack size) per PR. Path-filtered workflows keep this bounded.
  • Per-module CI is O(module size + consumer count) per PR. The consumer matrix grows with the number of consumers; ten consumers means ten plan jobs per module PR.
  • Per-stack integration CI (running consumer plans against the stack’s PR) is O(consumer count) per stack PR.

Production guidance

  1. One repo per state, or monorepo for states. Polyrepo wins below ~10 stacks; monorepo wins above.
  2. Modules in their own repos for small module sets, or in the monorepo’s modules/ directory for large module sets.
  3. Pin module sources to tags. Never consume main in production.
  4. Run consumers on module PRs. The integration job is the only place where module consumers are tested.
  5. Tag releases from CI. No laptop tags; no manual tags; the release pipeline tags the version on green CI.

Verification

The repo architecture is verified when:

  • Every module source has a ?ref=vX.Y.Z argument.
  • Every stack has a unique backend key.
  • Every module repo has a CI workflow that runs consumers.
  • Every stack repo has a CI workflow that runs plan.
  • Every release tag is created by CI, not by a human.

If any of those five fails, the architecture has a hole. Fix it before adding the next repo.

What comes next

The next lesson is Enterprise Module Ecosystem, which covers the private module registry, the contract between modules and consumers, and the governance that keeps the ecosystem honest.

Knowledge check · 6 questions

  1. Q1. How many Terraform states should a single repository hold in the polyrepo layout?

  2. Q2. Module repositories and stack repositories can be the same repository.

  3. Q3. What drives the choice of repo architecture? (Select all that apply.)

  4. Q4. What does per-stack CI do?

  5. Q5. A module upgrade silently breaks three consumers because their plans were never run against the new version. What is the fix?

  6. Q6. What does it mean to pin a module source to a tag?

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