TerraformXXVII · Enterprise Scale: Multi-Team, Multi-AccountProduction Terraform
The Repository Architecture
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
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
CODEOWNERSfile 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
secretsare 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
- One repo per state, or monorepo for states. Polyrepo wins below ~10 stacks; monorepo wins above.
- Modules in their own repos for small module sets, or
in the monorepo’s
modules/directory for large module sets. - Pin module sources to tags. Never consume
mainin production. - Run consumers on module PRs. The integration job is the only place where module consumers are tested.
- 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.Zargument. - 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
Q1. How many Terraform states should a single repository hold in the polyrepo layout?
Q2. Module repositories and stack repositories can be the same repository.
Q3. What drives the choice of repo architecture? (Select all that apply.)
Q4. What does per-stack CI do?
Q5. A module upgrade silently breaks three consumers because their plans were never run against the new version. What is the fix?
Q6. What does it mean to pin a module source to a tag?
Passing score: 75%. Answers are checked in this browser.