CI/CD Monorepo Strategies That Scale
Learn how to design CI/CD pipelines for monorepos using affected detection, build graphs, and caching to keep builds fast as the repo grows.
What you'll learn
- ✓Why naive monorepo pipelines collapse
- ✓How affected detection limits work
- ✓How to model a build graph
- ✓Caching layers that actually pay off
- ✓How to ship release trains safely
Prerequisites
- •Familiar with shell
- •Used GitHub Actions or similar
What and Why
A monorepo holds many projects in one repository. The promise is shared code, atomic refactors, and a single source of truth. The challenge is CI. If every push triggers a full rebuild of 200 packages, your pipeline becomes a 90 minute wall clock penalty and your team starts merging without waiting.
Monorepo CI/CD strategies exist to answer one question: given a change set, what is the minimum work required to verify it and ship the affected services?
Mental Model
A monorepo is a directed acyclic graph (DAG) of projects. Each project has source files and depends on other projects. When a file changes, the affected set is the project that contains it plus every project that transitively depends on it. Everything else is safe to skip.
[utils] ----> [auth] ----> [api]
\
\---> [web]
|
v
[admin]
change in utils -> affected: auth, api, web, admin
change in admin -> affected: admin only
Hands-on Example
Tools like Nx, Turborepo, Bazel, and Pants implement this graph. Here is a Turborepo example:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
}
}
}
In CI, you run turbo run build test --filter=...[origin/main] and Turborepo computes the affected set by comparing your branch to main.
A GitHub Actions workflow snippet:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
- run: npm ci
- name: Restore turbo cache
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ github.sha }}
restore-keys: turbo-
- run: npx turbo run build test --filter=...[origin/main]
For deploy steps, you read the affected set as JSON and matrix over it:
- id: affected
run: echo "pkgs=$(npx turbo run build --dry=json --filter=...[origin/main] | jq -c '[.tasks[].package] | unique')" >> $GITHUB_OUTPUT
deploy:
needs: build
strategy:
matrix:
pkg: ${{ fromJson(needs.build.outputs.pkgs) }}
steps:
- run: ./scripts/deploy.sh ${{ matrix.pkg }}
[git push]
|
v
[diff vs main]
|
v
[affected graph]
|
+---+---+
| |
[build] [test]
| |
+---+---+
|
v
[matrix deploy]
Common Pitfalls
Shallow clones break affected detection. Without full history, origin/main does not exist locally and tools fall back to building everything. Always use fetch-depth: 0 or set up a merge base fetch.
Implicit dependencies on unmodeled files like Dockerfiles or schema files cause silent misses. If a Dockerfile changes outside any package, the dependency graph does not know, and the affected set is empty. Pull those files into the package boundary.
Global lockfile changes affect everything. Treat package-lock.json or pnpm-lock.yaml changes as repo-wide invalidation, then optimize by hashing the resolved dependency tree per package.
Skipping tests for unaffected packages is correct, but skipping integration tests that span multiple packages is dangerous. Mark cross-package smoke tests as always run or run them on a schedule.
Practical Tips
Invest in a remote cache from day one. Turborepo Remote Cache, Nx Cloud, or a self-hosted S3 bucket can convert a clean CI build from 30 minutes to 2 minutes by reusing artifacts from another developer or branch.
Enforce dependency boundaries with linting. If your graph claims web does not depend on admin, fail the build when someone imports across that boundary. This keeps the graph trustworthy.
Use release trains for shared libraries. When utils changes, version it and let consumers opt in, rather than redeploying every dependent service immediately. This is especially useful for monorepos that contain both frontend and backend.
Tag pipelines by package so logs and metrics are per-project. Aggregate flake rates per package to find rotting tests before they block the next refactor.
Wrap-up
Monorepo CI succeeds when you model the build graph explicitly, detect affected projects from git diffs, and cache outputs aggressively. Avoid shallow clones, capture all relevant files inside package boundaries, and lint your dependency rules. Done well, a monorepo with 200 packages can have faster CI than a polyrepo with 20 services, because cache hits and parallel execution scale with the graph, not with the repo size.
Related articles
- CI/CD CI/CD Deployment Strategies Overview
Compare rolling, blue/green, canary, shadow, and feature flag deployments. Learn when to pick each strategy and the trade-offs in risk and cost.
- CI/CD CI/CD Pipeline Caching Techniques
Speed up CI builds with dependency caches, layer caches, remote build caches, and content-addressed storage. Learn what to cache and what to skip.
- Next.js Building a Next.js Monorepo with Turborepo
Set up a fast, cache-friendly Next.js monorepo using Turborepo. Share UI, config, and types between apps without sacrificing build performance.
- CI/CD CI/CD Secrets Management Best Practices
Keep API keys, tokens, and database credentials safe in CI/CD with rotation, scoping, secret managers, and OIDC-based authentication.