Skip to content
compiler.dev

Monorepo CI With Nx or Turborepo: Affected Builds

In a monorepo, a one-line change should not rebuild and retest everything. Two ideas make that possible: run tasks only for projects affected by the change, and reuse results from earlier runs through a cache. Nx and Turborepo both provide both. This guide shows the GitHub Actions setup for each and the mistakes that quietly turn affected builds back into full builds.

The three levels of "only what changed"

  1. Workflow triggers. paths filters decide whether a workflow starts at all.
  2. Affected detection. The tool compares the change against a base commit and the project graph, and selects projects that depend on changed files.
  3. Task caching. For selected tasks, the tool hashes inputs and replays results if it has seen them. Remote caching shares those results between CI runs and developers.

Use all three. Each handles a different case.

Get the base commit right

Affected detection needs history. The default actions/checkout fetches a single commit, which gives the tool nothing to compare against. Either fetch full history or enough of it:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Full history is simple and fine for most repositories. For very large ones, a deeper shallow fetch (for example fetch-depth: 50) works as long as the merge base is inside it.

On pull requests, compare against the target branch (origin/main). On pushes to main, compare against the previous successful commit, not the previous commit, so a failed run does not hide changes. Nx ships an action for this:

- uses: nrwl/nx-set-shas@v4

It sets NX_BASE and NX_HEAD to the last successful run on the main branch and the current commit.

Nx setup

name: ci
on:
  pull_request:
  push:
    branches: [main]
jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: nrwl/nx-set-shas@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx nx affected -t lint test build

nx affected -t lint test build runs those targets only for affected projects, in dependency order, with local caching. It reads NX_BASE and NX_HEAD. Add --parallel=3 to control concurrency on a small runner. To reuse results across runs, connect Nx Cloud or another remote cache; without one, each CI run starts with an empty local cache.

Turborepo setup

Turborepo's filter syntax selects by git changes:

- run: npx turbo run lint test build --filter="...[origin/main]"
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

...[origin/main] means "packages changed since origin/main, plus everything that depends on them". With TURBO_TOKEN and TURBO_TEAM set, Turborepo uses a remote cache (Vercel's, or a self-hosted server that implements its API). Without remote cache, you can persist the local .turbo directory with actions/cache, using a rolling key. See the caching guide.

Define task inputs and outputs in turbo.json precisely. If inputs is too broad (for example including a file that changes every commit), tasks always miss the cache; if outputs omits a folder, replayed tasks produce incomplete results.

Remote caching: what you gain

A cache hit skips the work entirely and replays logs and files. In a typical PR that touches one package, the packages that depend on it rebuild and everything else is a hit. Effects are biggest when:

  • Several PRs touch the same code paths, so results are shared.
  • Developers and CI share a cache, so CI replays what a laptop built.
  • Tasks are deterministic. Tasks that read the clock, network or random seeds cannot be cached safely.

Treat remote cache credentials as secrets, and restrict write access to trusted branches. A poisoned cache is a supply-chain risk, so do not let pull requests from forks write to it. See securing GitHub Actions.

Trigger filters on top

Cheap filtering at the workflow level helps when parts of the repo are unrelated, such as a docs site or infrastructure code:

on:
  pull_request:
    paths:
      - "apps/**"
      - "packages/**"
      - "package-lock.json"
      - ".github/workflows/ci.yml"

Include the workflow file itself and lockfiles. If the workflow is a required check, a skipped run leaves the check "Expected" forever; handle that with an always-running summary job or by path detection inside the workflow.

Pitfalls

  • Shallow clone makes the affected set empty or everything, depending on the tool.
  • Global files such as the root package.json, lockfile or tsconfig change the hash of every project. That is correct, but make sure unrelated files are not listed as global inputs.
  • Implicit dependencies, for example a service that reads a schema file from another package without declaring the dependency. Affected detection misses these. Declare them in the project config.
  • Deploys should not rely on affected-only builds without a full-build safety net on main or nightly.
  • Cache size: a remote cache grows without bound unless you set a retention period.

Measure the benefit

Track the median and 90th-percentile CI time for PRs before and after. Also track the fraction of tasks served from cache. If it stays below about half after a week, look at inputs and base-commit settings. Use the CI minutes forecaster to turn saved minutes into a monthly figure, and the test sharding planner if affected projects still run long suites.

FAQ

Nx or Turborepo?

Both do affected runs and caching. Nx has a richer project graph, generators and distributed task execution; Turborepo is lighter and simpler to add to an existing npm workspace. Choose by what you need beyond caching.

Do I still need path filters?

Yes for cheap cases: they avoid starting a runner at all. The tools then handle the finer decisions.

Does this work for non-JavaScript repos?

Nx has plugins for several languages, and tools like Bazel, Pants and Gradle have their own caching and affected logic. The principles (base commit, graph, cache) are the same.

Why is everything affected on every PR?

Usually a changed global file, a shallow checkout, or a wrong base SHA. Print the affected list with nx show projects --affected to debug.

Where compiler.dev fits

Affected builds reduce how many jobs run; faster runners reduce how long each takes. compiler.dev's comparison mode runs your existing workflow on faster machines so you can measure the second effect on your own repository.

Made by compiler.dev. Free tools · Pricing