Skip to content
C Codeloom
CI/CD

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.

·4 min read · By Codeloom
Intermediate 9 min read

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
Affected detection in a monorepo dependency graph

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]
CI pipeline reading the build graph

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.