Skip to main content
RunBook Academy

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

Monorepo vs Polyrepo

Advanced⏱ ~12 minbash

What you'll learn

  • Compare the monorepo and polyrepo layouts for Terraform configurations
  • Choose the right layout for a given team size and ownership model
  • Configure CODEOWNERS and path-filtered pipelines to enforce per-stack boundaries in a monorepo
  • Describe the migration path from polyrepo to monorepo in stages
  • Identify the failure modes of an undisciplined monorepo

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 repository layout is a decision about how teams discover, review, and release Terraform code. The polyrepo layout — one repo per state — is the default for small teams. The monorepo layout — every state in one repo — is the default for large teams. Both have costs; the lesson is when each cost is worth paying.

What “monorepo” means for Terraform

A Terraform monorepo is a single Git repository that contains every Terraform configuration (root module) the organisation runs. Each root module is a top-level directory; each is its own state; each has its own backend configuration.

terraform-mono/
├── modules/                       # shared, internal modules
│   ├── network/
│   │   ├── vpc/
│   │   └── transit-gateway/
│   ├── compute/
│   │   └── eks/
│   └── observability/
│       └── cloudwatch-alarms/
│
├── stacks/                        # root modules, each its own state
│   ├── prod-eu-network/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── prod.auto.tfvars
│   ├── prod-eu-compute/
│   ├── prod-us-network/
│   ├── prod-us-compute/
│   ├── nonprod-eu-dev/
│   └── nonprod-eu-staging/
│
├── .github/
│   ├── CODEOWNERS
│   └── workflows/
│       ├── plan-prod-eu-network.yml
│       ├── plan-prod-eu-compute.yml
│       ├── plan-nonprod-eu-dev.yml
│       └── ...
│
└── README.md

Each stacks/<name>/ directory is an independent root module with its own backend. The repo as a whole has no terraform init; each stack runs its own. The connection between stacks is via the modules/ directory (internal modules) and via remote-state reads between stack outputs.

A polyrepo layout would have a separate Git repository for each of those stacks/<name>/ directories, plus a separate repository (or several) for the modules/ directory.

When the monorepo wins

The monorepo pays for itself when:

┌──────────────────────────────────────────────────────────────┐
│  Cross-stack changes are common                              │
│  ────────────────────────────────                            │
│  A change to a module that touches three stacks at once is  │
│  one PR in a monorepo; it is three PRs in a polyrepo.        │
│                                                              │
│  Discoverability matters                                     │
│  ─────────────────────                                       │
│  New engineers can find every state in the estate in one    │
│  place. They can read the entire codebase without learning  │
│  the multi-repo access model.                                │
│                                                              │
│  Centralised policy enforcement                              │
│  ─────────────────────────────────                           │
│  CODEOWNERS, branch protection, required checks, and         │
│  pre-commit hooks all live in one place.                     │
│                                                              │
│  Tooling can index across stacks                             │
│  ─────────────────────────────────                           │
│  Static analysis, security scanning, and cost estimation    │
│  can run across the whole estate in one CI job.              │
└──────────────────────────────────────────────────────────────┘

For a small organisation (one team, fewer than ten stacks), none of these pay off yet. The monorepo’s overhead (CODEOWNERS, path-filtered pipelines, central CI configuration) is more expensive than the duplication of three polyrepo PRs.

When the polyrepo wins

The polyrepo pays for itself when:

  • The team is small and the stacks are independent. One PR per stack is fine; discoverability across the estate is not needed.
  • The stacks have very different access models. A regulated workload’s repository may need a separate approval chain that a non-regulated workload’s repository does not.
  • The organisation has multiple engineering cultures. Two acquisitions on different stacks often end up as two polyrepo estates because the cultural integration is not there yet.
  • The stacks have very different change cadences. A networking stack that changes weekly and a service stack that changes daily do not benefit from being in the same repo; the slow stack’s reviewers are spammed by the fast stack’s notifications.

The tooling that makes the monorepo work

A monorepo without tooling is a polyrepo with extra steps. The three tools that turn a monorepo into a working monorepo are:

CODEOWNERS. Every path under stacks/ is owned by a team; every path under modules/ is owned by the platform team. The CODEOWNERS file is the only place the team boundary is enforced.

# .github/CODEOWNERS

# Default owners for everything
*                                                 @platform-admins

# Platform team — owns network, IAM, observability
/modules/network/                                 @network-team
/modules/observability/                           @observability-team

# Workload teams — own their stacks
/stacks/prod-eu-network/                          @network-team
/stacks/prod-eu-compute/                          @platform-team
/stacks/nonprod-eu-dev/                           @dev-team

# Catch-all for stacks
/stacks/                                          @stack-owners

Path-filtered pipelines. Every GitHub Actions workflow under .github/workflows/ declares a paths filter that limits the workflow to the directory it serves. Without the filter, a PR that touches one stack runs every workflow in the repo.

# .github/workflows/plan-prod-eu-network.yml
name: plan-prod-eu-network
on:
  pull_request:
    paths:
      - 'stacks/prod-eu-network/**'
      - 'modules/network/**'
      - '.github/workflows/plan-prod-eu-network.yml'

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 init
        working-directory: stacks/prod-eu-network
        run: terraform init
      - name: terraform validate
        working-directory: stacks/prod-eu-network
        run: terraform validate -no-color
      - name: terraform plan
        working-directory: stacks/prod-eu-network
        env:
          AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN_PROD_EU }}
        run: terraform plan -no-color -out=tfplan

A PR that touches stacks/prod-eu-network/ runs this workflow. A PR that touches stacks/prod-us-compute/ does not. The filter is what makes the per-stack discipline work in a shared repo.

Per-stack remote backends. Every stacks/<name>/backend.tf points at a different state key. The keys live in a shared backend bucket but the keys are namespaced so a PR cannot write to a stack it does not own.

# stacks/prod-eu-network/backend.tf
terraform {
  backend "s3" {
    bucket         = "tf-state-platform-prod"
    key            = "stacks/prod-eu-network/terraform.tfstate"
    region         = "eu-west-2"
    dynamodb_table = "tf-locks-platform"
    encrypt        = true
  }
}
# stacks/nonprod-eu-dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "tf-state-platform-nonprod"
    key            = "stacks/nonprod-eu-dev/terraform.tfstate"
    region         = "eu-west-2"
    dynamodb_table = "tf-locks-nonprod"
    encrypt        = true
  }
}

The state keys are namespaced by stack, and the state buckets are scoped by environment. A PR to stacks/nonprod-eu-dev/ cannot touch the prod bucket even if the developer mistakenly runs terraform init against the wrong backend.

Migrating from polyrepo to monorepo

The migration is staged, not a single cutover. The plan:

  Phase 1   Introduce the monorepo skeleton.
            ──────────────────────────────
            Create terraform-mono/ with the modules/
            directory populated from the existing module
            repos. No stacks yet; the existing polyrepo
            stacks continue to work.

  Phase 2   Migrate one stack as a pilot.
            ─────────────────────────────────
            Pick the lowest-risk stack (typically nonprod
            dev). Move it to stacks/<name>/ in the
            monorepo. Cut over the CI pipeline. Run the
            monorepo and the polyrepo in parallel for one
            change cycle.

  Phase 3   Migrate the remaining stacks.
            ──────────────────────────────
            One stack per week. Cut over CI for each
            stack. Decommission the old polyrepo at the
            end of the phase.

  Phase 4   Decommission the polyrepo infrastructure.
            ────────────────────────────────────────
            Archive the polyrepo repositories. Move the
            polyrepo CODEOWNERS rules into the monorepo's
            CODEOWNERS. Audit the bucket policies to
            confirm no live state points at the old repos.

The pilot matters. If the pilot breaks, the migration has not succeeded; it has produced a partial migration that is harder to finish than the original monorepo would have been. Pick the stack with the lowest blast radius for the pilot.

How to validate the monorepo

# 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"
stacks/nonprod-eu-dev/backend.tf:     key = "stacks/nonprod-eu-dev/terraform.tfstate"

Every key is namespaced by stack. If two stacks share a key, the boundary is broken.

# READ-ONLY: every path-filtered workflow has a paths filter.
grep -L 'paths:' .github/workflows/*.yml || true
.github/workflows/lint.yml

The only workflow without a paths: filter is the central lint workflow, which runs on every PR. Every other workflow is filtered.

# READ-ONLY: CODEOWNERS covers every stack and module.
gh api repos/ORG/REPO/contents/.github/CODEOWNERS --jq '.content' \
  | base64 -d \
  | grep -E '^/stacks/|^/modules/'

The output lists every stack and every module with its owner. If a stack or module is missing, the boundary is incomplete.

Production failure modes

1. No path filters on per-stack workflows. Symptom: a PR that touches one stack runs every workflow in the repo; the PR takes 40 minutes to merge. Cause: the workflow was copied from a polyrepo and the filter was not added. Recovery: add paths: filters; document the rule in the contribution guide.

2. CODEOWNERS missing a stack. Symptom: a PR merges without a review from the stack owner. Cause: a new stack was added under stacks/ but the CODEOWNERS file was not updated. Recovery: add the path; add a CI check that fails if a new directory under stacks/ is not in CODEOWNERS.

3. State key collision. Symptom: two stacks write to the same state key, overwriting each other’s state. Cause: the backend key was hardcoded with the wrong prefix. Recovery: audit all backend blocks; ensure each key is unique; add a CI check that fails on duplicate keys.

4. Monorepo bootstrapped without a pilot. Symptom: the migration starts with five stacks on day one; the migration stalls when the team discovers the CODEOWNERS rules are wrong. Cause: the pilot phase was skipped. Recovery: roll back the five-stack migration to one stack; complete the pilot; resume the staged migration.

5. Module changes silently break consumers. Symptom: a change to modules/network/vpc/ breaks three downstream stacks that consume it. Cause: the module pipeline does not run consumers as integration tests. Recovery: add a module-ci workflow that runs terraform plan against every downstream stack on every PR to the module.

6. Polyrepo leftovers after migration. Symptom: a decommissioned polyrepo still has live state; a CI job still references the old repository. Cause: the decommission phase was skipped or rushed. Recovery: audit the state buckets for keys that point at the old polyrepo paths; archive the polyrepos; remove the CI job.

7. Monorepo size slows git operations. Symptom: git clone takes ten minutes; git log takes a minute. Cause: the monorepo has grown past the comfortable size for git (roughly 1 GB of history). Recovery: enable shallow clones in CI; consider git lfs for large assets; review whether all assets belong in the same repo.

Security implications

  • The CODEOWNERS file is the trust anchor for the entire monorepo. A PR to modules/network/ requires the network team; a PR to stacks/prod-eu-network/ also requires the network team. Bypass the rule and the boundary is gone.
  • Per-stack state keys in a shared backend bucket mean the bucket policy must be tight. A policy of "Resource": "arn:aws:s3:::tf-state-platform-prod/stacks/*" is correct; "Resource": "*" is a tenant isolation failure.
  • The path-filtered pipelines are the blast-radius control. A workflow that runs on every PR is a workflow that can affect every stack; the filter is the boundary.

Performance implications

  • A monorepo’s CI cost grows with the number of stacks. Every PR runs only the workflows whose paths: filter matches; the cost is per-stack, not per-repo.
  • A monorepo’s plan cost grows with the number of stacks. Each stack plans independently; the total plan time is the sum of the per-stack plan times.
  • A monorepo’s git cost grows with the repo size. Shallow clones and git lfs are mitigations; the right answer at extreme size is to consider splitting into a federated monorepo (one repo per organisation, separate CI) or a re-introduction of polyrepo for the largest stacks.

Production guidance

  1. Monorepo once you have more than ten stacks. Smaller estates do not pay for the tooling overhead.
  2. CODEOWNERS, path filters, per-stack backends. These three together make the monorepo safe. Without any one of them, the boundary is fictional.
  3. Pilot with the lowest-risk stack. One stack, one week, one cutover. Then the next stack.
  4. Module pipelines run consumers. A change to a module must run terraform plan against every downstream stack as an integration test.
  5. Decommission the polyrepo properly. Audit the state buckets; archive the repositories; remove the CI jobs.

Verification

The monorepo is verified when:

  • Every stack has a unique backend key in a per-environment bucket.
  • Every per-stack workflow has a paths: filter that matches the stack’s directory.
  • Every path under stacks/ and modules/ is in CODEOWNERS.
  • The module pipeline runs terraform plan against every consumer on every PR to a module.
  • No decommissioned polyrepo has live state in a production bucket.

If any of those five fails, the monorepo’s boundary has a hole. Fix it before adding the next stack.

What comes next

The next lesson is The Repository Architecture, which addresses how the monorepo decision interacts with module repositories and stack repositories — the third axis of the repo strategy.

Knowledge check · 6 questions

  1. Q1. What is the main benefit of a Terraform monorepo at enterprise scale?

  2. Q2. A monorepo eliminates the need for CODEOWNERS.

  3. Q3. Which of the following are required to make a Terraform monorepo safe? (Select all that apply.)

  4. Q4. When is a monorepo the wrong choice?

  5. Q5. A monorepo has 50 stacks. Every PR runs every pipeline and takes 40 minutes. What is the fix?

  6. Q6. How should the polyrepo-to-monorepo migration be staged?

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