Monorepo CI in 2026: Nx vs Turborepo vs Bazel vs Pants vs moon

Nov 10, 2026 · 12 min · Ajay Kumar

Every monorepo I have been asked to look at had the same CI file at its core: install everything, build everything, test everything, on every pull request. It works for the first year. Then the repo has forty packages, a one-line README fix takes 25 minutes of runner time, and somebody suggests splitting the repo. This post explains how monorepo CI actually gets fast in 2026: what affected detection and remote caching are, how Nx, Turborepo, Bazel, Pants and moon each implement them, where monorepos genuinely make CI worse, and why coding agents opening more pull requests make all of this more urgent rather than less. Versions below are the latest releases as of 10 November 2026, checked against each project's GitHub releases page.

Why does monorepo CI get slow?

A monorepo puts many projects in one repository so a change to a library and its consumers lands in one commit. A naive pipeline cannot tell which projects a commit touched, so it treats every commit as a change to everything, and cost grows with the size of the repo instead of the size of the change.

The fix has two parts, and every tool in this post implements both:

  1. Affected (or changed) detection. Diff the commit range in Git, map changed files to projects or targets, walk the dependency graph to find everything downstream, and run tasks only for that set.
  2. Content-addressed caching. Hash each task's inputs (source files, dependency outputs, tool versions, declared environment variables). If the hash has been seen before, restore the outputs and logs instead of running the task. Put that cache on a server and every runner, branch and laptop shares it.

Detection decides what is a candidate to run; the cache decides what actually runs. Detection is only as good as your base commit, and the cache only as good as your input declarations.

Volume is the other half of the story. GitHub's Octoverse 2025 counted 518.7 million merged pull requests in the year, up 29 percent, and 11.5 billion free Actions minutes used by public projects, up 35 percent. More PRs means more pipeline runs, and in a monorepo each run is as expensive as the repo is big unless you do the two things above.

How affected detection works in each tool

The idea is the same everywhere; the unit of change differs, and that unit is what decides how much work a small commit triggers.

Turborepo works at the package level. turbo run build --affected is, per the run reference, equivalent to --filter=...[main...HEAD]: packages with changed files, plus everything that depends on them. If any file in a package changes, all of that package's tasks are selected. You can move the base with TURBO_SCM_BASE and the head with TURBO_SCM_HEAD.

Nx goes a level finer. Its affected docs say Nx selects a task when the change touches one of that task's declared inputs, then adds tasks that read a selected task's outputs. So a change to a test fixture can select test without selecting build, if your inputs are declared that way. Base and head come from --base/--head or NX_BASE/NX_HEAD, and the nrwl/nx-set-shas action (v5.1.0) sets them to the last successful CI run on main.

Bazel works at the target level, the finest grain here, but has no built-in "affected" command. The common answer is bazel-diff, which hashes the whole build graph at two revisions and prints the impacted targets. Bazel's precision comes from its strictness: every input is declared in a BUILD file, so the graph is trustworthy.

Pants has it built in: pants --changed-since=origin/main --changed-dependents=transitive test. The target selection docs recommend passing the merge base, not the branch tip, on PRs, and warn that a lockfile change can mark every user of any third-party dependency as changed.

moon wraps it in one command. moon ci compares HEAD against a base it detects from the CI provider, then runs the affected tasks marked runInCI, with --job and --job-total to split the work across a matrix (CI guide). MOON_BASE and MOON_HEAD override detection.

All five need Git history between base and head. Turborepo's docs are blunt that if the checkout is too shallow, all packages are considered changed. I reproduced that with a --depth=1 clone and Turborepo 2.11.10, which printed unable to detect git range, assuming all files have changed and selected every package. That fallback is safe, and it is also the most common reason "affected" CI is no faster than the old pipeline.

Remote caching: what is shared, and who can write to it

A local cache only helps the runner that built it. CI runners are usually fresh VMs, so the speed-up comes from a remote cache that every job reads.

Two protocol families exist. Turborepo and Nx each define their own small HTTP API: Turborepo publishes an OpenAPI spec for self-hosting, and Nx publishes one for a custom cache server with PUT and GET on /v1/cache/{hash}, configured through NX_SELF_HOSTED_REMOTE_CACHE_SERVER. Bazel, Pants and moon all speak the Bazel Remote Execution API (REAPI) over gRPC, which means one cache server can back all three. bazel-remote (v2.6.4) is the usual self-hosted choice; Bazel's own caching docs also list nginx with WebDAV and Google Cloud Storage for the HTTP flavour.

# One self-hosted REAPI cache (bazel-remote v2.6.4) shared by Bazel, Pants and moon.
# HTTP on 9090, gRPC on 9092, capped at 50 GiB of disk.
docker run -d -u 1000:1000 -v /srv/bazel-remote:/data \
  -p 9090:8080 -p 9092:9092 buchgr/bazel-remote-cache:v2.6.4 \
  --max_size 50
# .bazelrc (Bazel 9.3.2): read and write on main, read-only on PRs
build --remote_cache=grpc://cache.internal:9092
build:pr --remote_upload_local_results=false
# pants.toml (Pants 2.32): same server, REAPI instance "main"
[GLOBAL]
remote_cache_read = true
remote_cache_write = true
remote_store_address = "grpc://cache.internal:9092"
remote_instance_name = "main"
# .moon/workspace.yml (moon v2): host is the only required key
remote:
  host: "grpc://cache.internal:9092"
  cache:
    compression: "zstd"   # moon's docs: match bazel-remote's --storage_mode (default zstd)

The hosted options are cheap. Vercel's Remote Cache for Turborepo is free on all plans, even if you host nothing on Vercel. Nx Cloud has a free Hobby plan with 50,000 credits a month and five contributors, and a Team plan listed at $29 a month, with contributors as a $19 add-on each.

The part people skip is write access. A cache entry is trusted by everyone who reads it, so anyone who can write can, in principle, plant an output that a release build later restores. In June 2025 Nx disclosed CVE-2025-36852 ("CREEP"), a race in bucket-based caches where an untrusted PR build writes the entry for a hash before the trusted build does, and in May 2026 it deprecated its own S3, GCS, Azure and shared-filesystem cache plugins, saying the flaw is in their design and cannot be patched. That disclosure comes from a vendor that sells the alternative, so read it with that in mind, but the mechanism is real and applies to any cache where PR jobs hold write credentials. The rule I apply everywhere: only protected-branch builds write; pull requests read. Bazel's --remote_upload_local_results=false, Nx's read-only CI tokens and Turborepo's --cache=local:rw,remote:r all express that. Turborepo's remoteCache.signature HMAC option rejects corrupted or unsigned artifacts, and its docs say plainly it is an integrity check rather than a security feature.

Nx vs Turborepo vs Bazel vs Pants vs moon compared

Tool Latest release Licence Unit of change Affected detection Remote cache Source
Turborepo 2.11.10 (20 Oct 2026) MIT package --affected, --filter=...[ref] Vercel (free) or self-hosted OpenAPI server release, docs
Nx 24.0.5 (6 Nov 2026; 24.0.0 on 21 Oct) MIT task inputs nx affected -t Nx Cloud or self-hosted OpenAPI server release, docs
Bazel 9.3.2 (5 Nov 2026; 9.0 LTS on 20 Jan) Apache-2.0 target external: bazel-diff v76.0.0 REAPI gRPC or HTTP release, docs
Pants 2.32.2 (1 Nov 2026) Apache-2.0 file and target --changed-since, --changed-dependents REAPI release, docs
moon 2.6.5 (29 Oct 2026) MIT project task moon ci REAPI gRPC or HTTP release, docs
Monorepo CI tools, November 2026: how each decides what to run Turborepo 2.11.10 Unit: package Flag: --affected Cache: Vercel (free) or self-hosted OpenAPI Typical fit: JS/TS, small teams Nx 24.0.5 Unit: task inputs Flag: nx affected -t Cache: Nx Cloud or OpenAPI server Typical fit: JS/TS plus plugins Bazel 9.3.2 Unit: target Tool: bazel-diff Cache: REAPI (gRPC) or plain HTTP Typical fit: large, polyglot Pants 2.32.2 Unit: file and target Flag: --changed-since Cache: REAPI (gRPC) Typical fit: Python, Go, JVM moon 2.6.5 Unit: project task Command: moon ci Cache: REAPI or HTTP Typical fit: mixed JS and Rust Bazel, Pants and moon can share one REAPI cache (e.g. bazel-remote). Turborepo and Nx use own HTTP APIs.
Unit of change, affected mechanism and cache protocol per tool, from each project's November 2026 release and documentation pages linked in the table. "Typical fit" is my judgement, not a vendor claim.

The practical split is between the JavaScript-first tools (Turborepo, Nx) and the hermetic, polyglot ones (Bazel, Pants). Turborepo adds a turbo.json and a binary to a pnpm or npm workspace. Nx adds more opinions, finer affected selection and distributed execution on Nx Cloud. Bazel 9 removed all WORKSPACE logic and made Bzlmod mandatory, per the 9.0 release notes, so new Bazel repos start cleaner than they did, but you still write BUILD files for everything. moon sits in between: language-agnostic task running with REAPI caching, without asking you to declare every input like Bazel does.

When does a monorepo hurt CI?

Monorepos hurt CI when the graph is wrong, the history is missing, or one file feeds every hash. The cases I see most:

  • Global inputs. A root tsconfig.json, a shared ESLint config or a lockfile that every package depends on means any change to it invalidates everything. Turborepo's globalDependencies and globalEnv are documented to cause every task to miss when they change. Keep that list short and deliberate.
  • Shallow checkouts. actions/checkout defaults to one commit. With no base in the clone, every tool either errors or treats everything as changed.
  • Path filters on required checks. GitHub documents that a workflow skipped by path filtering leaves its checks "Pending", which blocks merges if they are required. A skipped job inside a workflow that ran, on the other hand, reports Success. Use the second pattern.
  • Cache storage. The GitHub Actions cache defaults to 10 GB per repository and evicts entries unused for 7 days. A big monorepo stuffing .turbo or .nx directories into it will churn. A real remote cache avoids that.
  • Undeclared inputs. Environment variables, generated files and tool versions not in the hash produce either false hits (wrong output restored) or permanent misses. Turborepo's default strict env mode only passes declared variables to tasks, which turns the first problem into a loud build failure, which is the better failure.
  • Clone size. A full-history clone of a large repo can take longer than the build it enables. Partial clones (filter: blob:none) keep history without downloading every old blob.

None of these is a reason to split the repo; polyrepos move the same problem into version pins, where no tool computes the affected set for you. If you are still on a pipeline that runs everything, the GitHub Actions caching guide covers the non-monorepo basics first.

How AI coding agents change monorepo build load

Coding agents produce lots of small pull requests, and they iterate by pushing commits and reading CI output. Octoverse counted over a million PRs opened by Copilot's coding agent alone between May and September 2025. I covered the review side of that in CI/CD when agents open the pull requests; the build side is simpler arithmetic. If each push runs the full monorepo pipeline, agent throughput is capped by runner minutes, and your bill scales with how chatty the agent is.

Affected builds turn that around, because agent changes are usually narrow. A three-file change in one package should rebuild one package and its dependents, and restore the rest from cache. Three things matter more with agents than with people:

  1. Accurate base SHAs. Agents often push many commits to one branch. Compare against the merge base with the target branch, not the previous commit, or you miss changes from earlier pushes.
  2. Read-only caches on agent branches. An agent branch is exactly the kind of untrusted writer CREEP describes. Give PR and agent jobs read-only cache credentials.
  3. Same cache locally and in CI. If the agent runs turbo test or nx affected in its sandbox against the same remote cache, it gets fast feedback before it pushes, and CI then hits cache for the same hashes.

This is old-fashioned platform work: a dependency graph, a cache with sensible permissions and a pipeline that respects both. AI made the cost of skipping it visible on the invoice. If you run your own runners for this, the trade-offs are in self-hosted GitHub Actions runners.

Which monorepo tool should you pick?

My rules of thumb, for a team choosing in late 2026:

  • JS/TS workspace, want results this week: Turborepo. Add --affected and the free Vercel cache, done.
  • JS/TS, larger org, want finer affected selection or distributed CI: Nx, and budget for Nx Cloud or a self-hosted cache server that implements its OpenAPI spec.
  • Polyglot, hundreds of engineers, need hermetic builds: Bazel, with bazel-diff and a REAPI cache. Only if someone owns the build as a job.
  • Python-heavy with some Go or JVM: Pants, which infers dependencies from imports and avoids most hand-written BUILD boilerplate.
  • Mixed JS and Rust or other languages, want one task runner without Bazel's strictness: moon.

If CI is still slow on one of these, switching tools rarely helps; fixing the base SHA, clone depth and cache write policy usually does. The step-by-step version is in the companion guide, how to speed up monorepo CI with affected builds and remote caching.

FAQ

Is Nx or Turborepo better in 2026?

Turborepo is simpler and adds less to an existing package-manager workspace; its affected detection is package-level and its Vercel remote cache is free. Nx selects tasks by declared inputs, which can skip more work, and offers distributed execution through Nx Cloud. For a small JS/TS repo I would start with Turborepo; for a large one with many task types, Nx earns its extra configuration.

Does Bazel have an affected command?

Not built in. Most teams use bazel-diff, which hashes every target at the base and head revisions and lists the impacted ones, then pass that list to bazel build or bazel test. Bazel's remote cache does the rest, so even a broad target list mostly restores from cache.

Why does my affected build still run every package?

Almost always the base commit is missing. With a shallow checkout or no local main branch, Turborepo warns that it cannot detect the git range and treats everything as changed; Nx and moon need full history too. Fetch full history (a blobless partial clone is fine) and pass an explicit base SHA.

Is a shared remote cache a security risk?

It can be. Anyone who can write to the cache can influence what other builds restore, which is the mechanism behind CVE-2025-36852. Let only protected-branch builds write, give pull requests and agent branches read-only access, and keep release builds on a cache that untrusted jobs never write to.

This quarter I would do three boring things on any monorepo I touched: set fetch-depth: 0 with a blobless filter and an explicit base SHA, move every PR job to read-only cache credentials, and add a --summarize or --dry=json artifact to CI so cache misses can be explained instead of guessed at. Tool migrations can wait until those three are done and measured.

Related: How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026), CI/CD in 2026: Agents Opened 1M PRs in 5 Months, Bots Write 1 in 5 Reviews, and Most Agent PRs Get No Human Look and GitHub Actions vs GitLab CI vs Jenkins (2026): Pricing, Runners, Security.

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