How to Speed Up Monorepo CI With Affected Builds and Remote Caching (2026)

Nov 10, 2026 · 13 min · Ajay Kumar

This guide is for anyone running a JavaScript or TypeScript monorepo on GitHub Actions whose pull requests rebuild and retest everything. By the end you will have a pipeline that computes the affected set against a correct base commit, restores unchanged work from a remote cache that pull requests can read but not write, fans affected packages out across a matrix, and reports a single required check that never gets stuck. Commands were checked against Turborepo 2.11.10, Nx 24.0.5, nrwl/nx-set-shas v5.1.0 and actions/checkout v7.0.1, and the Turborepo outputs shown are from a real run in a scratch repo. You need a working pnpm or npm workspace and admin access to the repository's Actions settings. For the background on why this works and how Bazel, Pants and moon differ, read the companion post, Monorepo CI in 2026: Nx vs Turborepo vs Bazel vs Pants vs moon.

Step 1: Measure your current CI time

Do not skip this. Without a baseline you cannot tell whether a change helped, and "it feels faster" does not survive the first slow week. Pull the last 50 successful runs on main and compute median and p90 wall time:

# gh 2.49+; startedAt/updatedAt are run-level timestamps
gh run list --workflow ci.yml --branch main --limit 50 \
  --json conclusion,startedAt,updatedAt |
jq '[.[] | select(.conclusion=="success")
     | ((.updatedAt|fromdateiso8601) - (.startedAt|fromdateiso8601))/60]
    | sort | {runs: length, p50_min: .[length/2|floor], p90_min: .[(length*0.9)|floor]}'

Example output shape (from test data, not a real repository):

{
  "runs": 3,
  "p50_min": 12.5,
  "p90_min": 20
}

Repeat it with --event pull_request instead of --branch main, because PR runs are where affected detection pays off. Write both numbers down.

Step 2: Check out enough Git history

Affected detection diffs a commit range, so both ends of the range must exist in the clone. actions/checkout fetches one commit by default, and Turborepo's run reference says that if the checkout is too shallow, all packages are considered changed. Fetch full history, but use a partial clone so you do not download every historical blob:

- uses: actions/checkout@v7
  with:
    fetch-depth: 0       # full commit history, needed for the base SHA
    filter: blob:none    # partial clone: history yes, old file contents on demand

Turborepo's docs recommend blob:none; the nx-set-shas README uses tree:0, which is smaller still. Either works for affected detection.

Step 3: Resolve the base SHA

The base is the commit you compare against. For a pull request it should be where the branch diverged from main. For a push to main it should be the last commit that passed CI, not HEAD~1, or a failed run silently drops its changes from the next one. The nrwl/nx-set-shas action does both by querying the Actions API, and it works for Turborepo too: it exports NX_BASE and NX_HEAD, which you can copy into TURBO_SCM_BASE.

# workflow or job level: nx-set-shas reads previous workflow runs
permissions:
  contents: read
  actions: read
# steps, after checkout and install
- uses: nrwl/nx-set-shas@v5
  with:
    main-branch-name: main
- run: echo "TURBO_SCM_BASE=$NX_BASE" >> "$GITHUB_ENV"

Here is what happens without a resolvable base. I cloned a three-package Turborepo repo with --depth=1 and asked for the affected set:

$ npx turbo ls --affected
• turbo 2.11.10
 WARNING  unable to detect git range, assuming all files have changed: Unable to resolve base branch. Please set with `TURBO_SCM_BASE`.
3 packages (npm)

The same warning appeared in a full clone that had origin/main but no local main branch, which is exactly what a CI checkout looks like. With TURBO_SCM_BASE=origin/main set, only the two affected packages were listed.

Step 4: Run affected tasks with Turborepo

This is a complete workflow for a pnpm workspace with Turborepo 2.11.10 pinned in devDependencies and a packageManager field in package.json:

# .github/workflows/ci.yml
name: ci
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  actions: read
  id-token: write        # for the Vercel remote cache OIDC action (Step 6)

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          filter: blob:none
      - uses: pnpm/action-setup@v6          # reads packageManager from package.json
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: nrwl/nx-set-shas@v5
      - run: echo "TURBO_SCM_BASE=$NX_BASE" >> "$GITHUB_ENV"
      - uses: vercel/setup-turborepo-remote-cache-action@v1.1.0
        with:
          team: ${{ vars.TURBO_TEAM }}
      # main writes to the remote cache; pull requests only read it
      - run: >
          pnpm exec turbo run lint test build --affected --summarize
          --cache=local:rw,remote:${{ github.event_name == 'push' && 'rw' || 'r' }}
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: turbo-summary
          path: .turbo/runs/

--affected is shorthand for --filter=...[main...HEAD] with the base and head overridable by TURBO_SCM_BASE and TURBO_SCM_HEAD. It works at package level: if any file in a package changes, all its tasks are selected, plus every package that depends on it. In my scratch repo, changing one file in ui (which web depends on) gave:

$ npx turbo run build --affected
ui:build: cache miss, executing 3b99057b681d38e6
web:build: cache miss, executing aa4b244638493043
 Tasks:    2 successful, 2 total
Cached:    0 cached, 2 total

Running it again restored both from cache:

 Tasks:    2 successful, 2 total
Cached:    2 cached, 2 total
  Time:    21ms >>> FULL TURBO

Step 5: Run affected tasks with Nx

If you use Nx instead, the shape is the same. Nx reads NX_BASE and NX_HEAD directly, so the set-shas step is all you need, and per the affected docs it selects tasks whose declared inputs changed rather than whole projects.

      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          filter: tree:0
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - uses: nrwl/nx-set-shas@v5
      - run: npx nx affected -t lint test build --parallel=3
        env:
          # read-write token only on main; a read-only token for pull requests
          NX_CLOUD_ACCESS_TOKEN: ${{ github.event_name == 'push' && secrets.NX_CLOUD_RW_TOKEN || secrets.NX_CLOUD_RO_TOKEN }}

Selection by inputs only works if inputs are declared. A minimal nx.json that keeps test-only files out of the build hash:

{
  "defaultBase": "main",
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    }
  }
}

Step 6: Set up the remote cache with read-only pull requests

For Turborepo, the Vercel Remote Cache is free on all plans. The OIDC route avoids a long-lived token: create a Turborepo CLI OIDC policy in your Vercel team settings for this repository, add a TURBO_TEAM repository variable, grant id-token: write, and run vercel/setup-turborepo-remote-cache-action before any turbo command, as in Step 4. The action's README says a working setup logs Remote caching enabled.

gh variable set TURBO_TEAM --body "your-team-slug"

If you prefer a personal token, set TURBO_TOKEN as a secret and TURBO_TEAM as a variable in the job env. To self-host, any server implementing Turborepo's published OpenAPI spec works; point TURBO_API at it.

For Nx, run npx nx connect to link the workspace to Nx Cloud (the Hobby plan is free with 50,000 credits a month), then create two CI access tokens: read-write for main, read-only for pull requests, as in Step 5. To self-host, Nx documents a small cache server API configured with NX_SELF_HOSTED_REMOTE_CACHE_SERVER and NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN.

The read-only rule for pull requests is the important part. Nx's CVE-2025-36852 write-up describes how an untrusted build that can write to a shared cache can plant outputs a trusted build later restores. PRs, forks and AI agent branches read; protected branches write.

One CI run with affected builds and a remote cache 1. Checkout fetch-depth: 0 filter: blob:none 2. Base SHA nx-set-shas, or the PR base commit 3. Affected set turbo --affected nx affected -t 4. Hash inputs files, deps, env, tool versions 5. Cache lookup remote, then local keyed by task hash No base found everything counts as affected HIT restore outputs and logs MISS run the task then maybe upload Write policy Push to main: remote cache read and write. Pull requests, forks and agent branches: read only. Turborepo --cache=local:rw,remote:r, Nx read-only token, Bazel --remote_upload_local_results=false.
The order of operations in Steps 2 to 6. Flags and behaviours are from the Turborepo, Nx and Bazel documentation linked in this guide; the shallow-clone fallback was reproduced with Turborepo 2.11.10.

Step 7: Fix cache misses with run summaries

A remote cache that never hits is a slow upload step. The usual causes are inputs that differ between runs: an undeclared environment variable, a timestamp written into an output, a different Node version. Turborepo's --summarize writes a JSON file to .turbo/runs/ with each task's hash, inputs and environment variable hashes. Diff two of them:

# Compare the hash inputs for one task across two runs
A=.turbo/runs/first.json; B=.turbo/runs/second.json
diff <(jq -S '.tasks[] | select(.taskId=="ui#build") | {hash, inputs, env: .environmentVariables}' "$A") \
     <(jq -S '.tasks[] | select(.taskId=="ui#build") | {hash, inputs, env: .environmentVariables}' "$B")

With API_URL declared in the task's env and changed between two runs, that printed:

<       "API_URL=38612c965a9c4c35d713439919804ad960eb54d0ad41b4362ffd85343d4bbb4f"
>       "API_URL=35b22c6cbc7988f80d8ed1be0ec1bf8dea41c1b5e35c750530e44f02244751f1"
<   "hash": "da59e65e3610be9e",
>   "hash": "858ae3782fff708b",

That is the cache doing its job. The fix depends on intent: if the variable really changes the output, keep it in env; if it does not, remove it. Keep globalDependencies and globalEnv short, because the configuration reference says any change there makes every task miss. For Nx the causes are the same; tighten inputs as in Step 5 so files that do not affect a task stay out of its hash.

Step 8: Fan affected packages out across a matrix

When one job is still too slow, split the affected packages across parallel jobs. turbo ls --affected --output=json lists them; nx show projects --affected -t test --json does the same for Nx.

jobs:
  affected:
    runs-on: ubuntu-24.04
    outputs:
      packages: ${{ steps.ls.outputs.packages }}
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0, filter: blob:none }
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with: { node-version: 24, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: nrwl/nx-set-shas@v5
      - id: ls
        env:
          TURBO_SCM_BASE: ${{ env.NX_BASE }}
        run: |
          echo "packages=$(pnpm exec turbo ls --affected --output=json 2>/dev/null \
            | jq -c '[.packages.items[].name]')" >> "$GITHUB_OUTPUT"

  test:
    needs: affected
    if: needs.affected.outputs.packages != '[]'   # an empty matrix is an error
    runs-on: ubuntu-24.04
    strategy:
      fail-fast: false
      matrix:
        package: ${{ fromJSON(needs.affected.outputs.packages) }}
    steps:
      # same checkout, pnpm, node, install and cache steps as above
      - run: pnpm exec turbo run test --filter=${{ matrix.package }}

In my scratch repo the jq expression returned ["ui","web"]. Each matrix job still benefits from the remote cache, because --filter still runs the task's dependsOn chain (for example ^build in its dependencies), and those usually hit.

Step 9: Use path filters without blocking merges

on.pull_request.paths looks like a cheap win, but GitHub documents that a workflow skipped by path filtering leaves its checks "Pending", and a required pending check blocks the merge. A job skipped by an if: inside a workflow that ran reports Success instead. So skip jobs, not workflows, and make one gate job the only required check:

  ci-ok:
    if: always()
    needs: [affected, test]
    runs-on: ubuntu-24.04
    steps:
      - run: |
          echo '${{ toJSON(needs) }}' | jq .
          [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" ]

In branch protection, require ci-ok and nothing else. When nothing is affected, test is skipped, ci-ok passes, and the PR can merge. Then rerun Step 1 after a week and compare.

Troubleshooting

Symptom Likely cause Fix
unable to detect git range, assuming all files have changed Shallow clone, or no local main branch in CI fetch-depth: 0 and set TURBO_SCM_BASE from nx-set-shas or origin/main
nx affected runs every project No base commit in the clone, or NX_BASE unset Full history checkout and nrwl/nx-set-shas@v5 before the command
nx-set-shas warns no successful run found and uses HEAD~1 New workflow, renamed file, or no green run on main yet Set workflow-id, or fallback-sha, or error-on-no-successful-workflow: true
Required check stuck on Waiting for status to be reported Workflow skipped by paths filter Run the workflow always, skip jobs with if:, require a single gate job
Matrix job errors when nothing is affected Empty JSON array passed to matrix Guard the job with if: needs.affected.outputs.packages != '[]'
Hits locally, misses in CI Different env vars, Node version or generated files in the hash Diff two --summarize files; fix env, inputs and pin tool versions
No Remote caching enabled line Missing OIDC policy, TURBO_TEAM variable or id-token: write Recheck Step 6; fork PRs do not receive OIDC tokens, so they run uncached
Task fails in CI because an env var is empty Turborepo strict env mode passes only declared variables Add the variable to the task's env (or passThroughEnv if it must not affect the hash)
.turbo cache in actions/cache keeps disappearing 10 GB per-repo limit and 7-day eviction (GitHub docs) Use a remote cache instead of actions/cache for task outputs

FAQ

Should I set fetch-depth to 0 for a large monorepo?

Yes, but pair it with a partial clone filter (blob:none or tree:0). Affected detection needs the commit graph between base and head, not the contents of every old file. Without full history, Turborepo, Nx and moon all fall back to treating everything as changed or fail to resolve the base.

What base SHA should a pull request use?

The point where the branch diverged from the target branch. Turborepo's --affected uses a three-dot range (main...HEAD), which Git resolves to the merge base, and nx-set-shas returns the commit the PR originated from. For pushes to main, use the last commit that passed CI so failed runs do not drop changes.

Is the Turborepo remote cache free?

Turborepo's documentation says the Vercel Remote Cache is free on all Vercel plans, including for teams that do not host apps on Vercel. You can also self-host any server that implements Turborepo's published OpenAPI spec.

Can pull requests write to the remote cache?

They can, but they should not. A shared cache trusts whatever is written to it, so give PR, fork and agent jobs read-only access and let only protected-branch builds write. Turborepo's --cache=local:rw,remote:r and Nx's read-only CI tokens do this.

When a monorepo pipeline is slow, the first thing I check is not the build tool but the first line of the affected step's log: if it says it is assuming everything changed, nothing else in this guide matters until the base SHA is fixed. In production I keep the run summary as an artifact on every job, cache writes on main only, and one gate job as the required check. The official references are the Turborepo run reference, Turborepo GitHub Actions guide, Nx affected docs and the nx-set-shas README. If your pipeline is not a monorepo, start with how to speed up GitHub Actions CI instead.

Related: How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026), How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026) and GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related