TerraformXXVII · Enterprise Scale: Multi-Team, Multi-AccountProduction Terraform
Monorepo vs Polyrepo
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
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 tostacks/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 lfsare 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
- Monorepo once you have more than ten stacks. Smaller estates do not pay for the tooling overhead.
- CODEOWNERS, path filters, per-stack backends. These three together make the monorepo safe. Without any one of them, the boundary is fictional.
- Pilot with the lowest-risk stack. One stack, one week, one cutover. Then the next stack.
- Module pipelines run consumers. A change to a module
must run
terraform planagainst every downstream stack as an integration test. - 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/andmodules/is inCODEOWNERS. - The module pipeline runs
terraform planagainst 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
Q1. What is the main benefit of a Terraform monorepo at enterprise scale?
Q2. A monorepo eliminates the need for CODEOWNERS.
Q3. Which of the following are required to make a Terraform monorepo safe? (Select all that apply.)
Q4. When is a monorepo the wrong choice?
Q5. A monorepo has 50 stacks. Every PR runs every pipeline and takes 40 minutes. What is the fix?
Q6. How should the polyrepo-to-monorepo migration be staged?
Passing score: 75%. Answers are checked in this browser.