Skip to content
compiler.dev

Fastest CI for Nx Monorepos on GitHub Actions

Last updated

The fastest Nx CI does three things: run only affected projects (nx affected), reuse results through a remote cache, and spread the remaining work over several machines. This guide goes deeper on Nx than the general Nx and Turborepo guide, including the git settings that quietly break affected runs.

Step 1: affected-only runs

nx affected compares two commits and runs tasks only for projects that changed or depend on something that did. It needs a base and a head. The nrwl/nx-set-shas action works them out for you.

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          filter: tree:0
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: nrwl/nx-set-shas@v5
      - run: npx nx affected -t lint test build

Per the nx-set-shas README (checked October 2026), the action exports NX_BASE and NX_HEAD for the job and also exposes base and head as step outputs. On a pull request the base is the merge base with main. On a push to main it is the last commit whose workflow run succeeded, so a red commit does not hide its changes from the next run. If it cannot find one, it falls back to HEAD~1 unless you set error-on-no-successful-workflow: true.

The fetch-depth requirement

actions/checkout fetches one commit by default. With a single commit there is no base to compare, and nx affected either errors or treats everything as affected. The nx-set-shas README says it needs all branches and commits so Nx has a base to compare against, which is why the workflow uses fetch-depth: 0.

filter: tree:0 makes it a blobless-style partial clone, which keeps the full history cheap: you fetch commit metadata up front and file contents on demand. For huge repos this is often seconds instead of a minute.

Change the default base

Nx also reads defaultBase in nx.json (usually main). Keep it in sync with your trunk branch name or affected runs compare against the wrong ref.

Step 2: remote cache

Local caching only helps if the same runner sees the same files. On GitHub Actions each job starts clean, so you need a remote cache to reuse task outputs between runs, branches and teammates.

The Nx docs (checked October 2026) connect a workspace with npx nx-cloud onboard connect-workspace, which writes nxCloudId to nx.json. For CI authentication they describe read-only and read-write access tokens in their CI access tokens guide. Use a read-only token for pull requests from forks and read-write for trusted branches, so an untrusted PR cannot poison the cache.

Confirm hits in the log. A cached task prints [local cache] or [remote cache] and replays the output instead of running the command. A summary at the end reports how many tasks were read from cache.

Without Nx Cloud, you can use other remote cache implementations through Nx's custom task runner and cache plugins, or cache the .nx/cache directory with actions/cache. The Actions-cache version is the cheapest to try, with two limits: PR caches are scoped, and the repo's 10 GB cap applies. See cache limits.

- uses: actions/cache@v6
  with:
    path: .nx/cache
    key: nx-${{ runner.os }}-${{ github.sha }}
    restore-keys: |
      nx-${{ runner.os }}-

Step 3: distribute the tasks

After affected and caching, a large PR can still be slow. Nx can distribute tasks across agents. The Nx CI intro (checked October 2026) configures that in .nx/ci-config.yaml and starts agents in the workflow before install:

# .nx/ci-config.yaml
dte:
  distribute-on: 3 linux-large-js
lifecycle:
  stop-after:
    - build
steps:
  - uses: actions/checkout@v6
    with:
      fetch-depth: 0
      filter: tree:0
  - run: npx nx-cloud start-workers
  - uses: actions/setup-node@v7
    with:
      node-version: 22
      cache: npm
  - run: npm ci
  - uses: nrwl/nx-set-shas@v5
  - run: npx nx affected -t lint test build

Starting workers first lets them boot while your main job installs dependencies. Only tasks run through nx are cached and distributed, so make sure npm test wraps nx test rather than calling the test runner directly.

If you would rather stay on plain GitHub Actions, shard yourself with a matrix. Nx supports splitting by project, and for single large test suites you can shard the runner itself. See test sharding in GitHub Actions.

Putting it together

name: CI
on:
  push:
    branches: [main]
  pull_request:

permissions:
  actions: read
  contents: read

jobs:
  main:
    runs-on: [self-hosted, Linux, compiler-ci]   # or ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          filter: tree:0
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: nrwl/nx-set-shas@v5
      - run: npx nx affected -t lint test build --parallel=4

--parallel controls how many tasks run at once on the one machine. More cores let you raise it. The runs-on line is the only compiler.dev-specific part: it moves the job to an 8 vCPU machine at $0.012 per minute, and you change it back to undo. See pricing.

Mistakes that turn affected into everything

  1. Shallow checkout. The most common one. Use fetch-depth: 0.
  2. Touching a root file. Changing package-lock.json, nx.json or a shared tsconfig makes everything affected. Nx's namedInputs let you narrow what each target depends on.
  3. Base is a red commit. Using HEAD~1 after a failed run hides failures. nx-set-shas picks the last green one.
  4. Non-Nx scripts. Tasks outside nx skip cache and affected logic.
  5. A cache that never saves. Make sure a trusted branch writes it.

For slow installs, see npm caching or pnpm caching. For other monorepo tools, see Turborepo and Bazel.

FAQ

What does nx affected actually compare?

The files changed between NX_BASE and NX_HEAD, mapped through the project graph to projects and their dependents.

Do I need Nx Cloud?

Not for affected runs. You need a remote cache or distributed execution for cross-run reuse; Nx Cloud is the supported path, and actions/cache on .nx/cache is a limited alternative.

Why is nx affected running everything on my PR?

Check fetch depth, check that NX_BASE is not equal to NX_HEAD's ancestor in an unexpected way, and see whether a root config file changed.

Does this work with a merge queue?

nx-set-shas has a use-previous-merge-group-commit input (default true) for merge groups. See GitHub merge queue.

Run this monorepo build on compiler.dev 200 free minutes

Start free