How to Speed Up Monorepo CI With Affected Builds and Remote Caching (2026)
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.
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
How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026)
A step-by-step guide to faster GitHub Actions in 2026: measure job timings, cancel stale runs, use setup-node, setup-python and setup-go caching, actions/cache v6, buildx type=gha layer caching, matrix test sharding and larger runners, plus a cache-miss troubleshooting table.
13 minNov 10, 2026How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
Step-by-step ARC 0.15.1 setup for GitHub Actions self-hosted runners on Kubernetes: GitHub App auth, pinned Helm charts, runner scale sets, runner groups, Docker-in-Docker vs kubernetes container mode, scale to zero, metrics, and fixes for jobs stuck in queued.
13 minNov 10, 2026How to Deploy Airflow 3 on Kubernetes with the Helm Chart (2026)
Step-by-step guide to running Apache Airflow 3.4 on Kubernetes with the official Helm chart 1.22.0: KubernetesExecutor vs Celery, git-sync Dags, external Postgres with PgBouncer, Vault secrets, KubernetesPodOperator and upgrading from Airflow 2.
14 min