Monorepo vs Multi-Repo
One of the earliest and most consequential decisions in an Infrastructure as Code practice is where the code lives. Do you keep every Terraform configuration, module, and environment in a single repository, or do you split infrastructure across many focused repositories owned by different teams? The choice shapes your blast radius, your pipelines, your dependency management, and how easily engineers can reason about changes. This page compares both models, weighs the trade-offs, and gives you a framework for deciding.
What each model means
A monorepo stores all infrastructure code in one version-controlled repository. Shared modules, environment configurations, and root stacks live side by side. A change to a module and the stacks that consume it can land in a single atomic commit.
A multi-repo (sometimes called polyrepo) splits infrastructure into many repositories, typically one per team, service, or domain. Each repo owns its own pipeline, its own state, and its own release cadence. Shared modules are usually published as versioned artifacts and pulled in by reference.
Both work with Terraform 1.5+ and OpenTofu identically — neither tool cares how your filesystem is organized across repositories. The difference is entirely about workflow, ownership, and coordination.
A typical monorepo layout
In a monorepo, you co-locate modules with the stacks that use them and reference them by relative path.
# environments/prod/network/main.tf
module "vpc" {
source = "../../../modules/vpc"
cidr_block = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
enable_nat_gateway = true
environment = "prod"
}
output "vpc_id" {
value = module.vpc.vpc_id
}
Because the module is referenced by path, editing modules/vpc and the prod stack in one commit guarantees they stay consistent. Running a plan from the stack directory picks up the local change immediately:
cd environments/prod/network
terraform init
terraform plan
Output:
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.vpc.aws_vpc.this will be created
+ resource "aws_vpc" "this" {
+ cidr_block = "10.20.0.0/16"
+ id = (known after apply)
}
Plan: 9 to add, 0 to change, 0 to destroy.
A typical multi-repo layout
In a multi-repo setup the vpc module lives in its own repository and is published with a version tag. Consuming repos pin to a specific version, so a module change does not affect anyone until they deliberately upgrade.
# In the "platform-network" repo
module "vpc" {
source = "git::https://github.com/acme/terraform-aws-vpc.git?ref=v2.4.1"
cidr_block = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
enable_nat_gateway = true
environment = "prod"
}
A private registry (Terraform Cloud, an internal registry, or an S3-backed source) achieves the same versioned isolation:
module "vpc" {
source = "app.terraform.io/acme/vpc/aws"
version = "~> 2.4"
# ... inputs
}
Always pin module versions in a multi-repo. A floating
ref=mainreintroduces the very coupling you split the repos to avoid — and makes plans non-reproducible across runs.
Trade-offs at a glance
| Dimension | Monorepo | Multi-repo |
|---|---|---|
| Atomic cross-cutting changes | Easy — one commit, one PR | Hard — coordinate multiple PRs |
| Module versioning | Optional (path refs) | Required (tags/registry) |
| Blast radius of a bad change | Larger by default | Naturally contained per repo |
| Access control / ownership | Coarse (one repo’s permissions) | Fine-grained (per team) |
| CI/CD pipeline | One pipeline, needs path filtering | Independent per repo |
| Discoverability | High — everything is searchable | Lower — code is scattered |
| Onboarding | Clone one repo, see the whole picture | Hunt across many repos |
| Scaling to many teams | Merge contention, slow CI | Scales cleanly |
Choosing between them
The decision usually comes down to organizational shape, not technology. Use this framework:
- Team count and autonomy. A handful of engineers sharing ownership favors a monorepo. Many independent teams that ship on their own schedules favor multi-repo.
- Change frequency across boundaries. If most changes touch several stacks at once, the monorepo’s atomic commits save real pain. If changes are mostly local to one service, isolation wins.
- Compliance and access. Strict per-team or per-environment access control is far easier with separate repos and separate pipelines.
- State management. Either model should still split state into many small backends; a monorepo is not a single state file. See Managing large state.
A common middle ground is a modular monorepo with path-scoped CI: keep one repo, but trigger pipelines only on changed directories so teams are not blocked by unrelated changes.
# Plan only the stacks affected by this push (path-filtered CI)
git diff --name-only HEAD~1 HEAD \
| grep '^environments/' \
| cut -d/ -f1-3 | sort -u \
| while read dir; do terraform -chdir="$dir" plan; done
Many teams start with a monorepo for velocity and split out repos only when CI contention or ownership boundaries become painful. Premature splitting adds versioning overhead before you need it.
Best practices
- Split Terraform state into small, independent backends regardless of repo strategy — repo layout and state layout are separate decisions.
- In multi-repo setups, always pin module sources to immutable version tags or registry constraints; never track a branch.
- Use path-filtered CI in a monorepo so a change in one environment does not run plans for the entire organization.
- Apply consistent naming conventions across all repos so resources remain discoverable wherever the code lives.
- Publish shared modules with semantic versioning and a changelog so consumers can upgrade deliberately.
- Document the dependency graph between stacks — implicit ordering is the most common failure mode in both models.
- Revisit the decision periodically; the right structure for five engineers is rarely the right structure for fifty.