Guide · Backend
Monorepo Build Systems Explained: Build Graphs, Caching and Affected Targets
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Monorepo build systems keep one big repository fast: they run tasks in dependency order, reuse earlier results from a cache and build only what a change affects. A monorepo is one git repository holding many apps and shared libraries.
- The build graph is a map of which package needs which. A task is one command, such as build or test, for one package. The graph decides the order and what can run in parallel.
- A hash is a short fingerprint computed from data; change one byte and it changes. The tool hashes each task's inputs: files, command, declared settings and its dependencies' hashes. A matching hash means the saved output is reused.
- A remote cache shares those saved outputs between machines. Affected-target selection compares your branch with main, the shared branch everyone merges into, and runs only changed packages and the packages that depend on them. Together they cut CI (continuous integration, the servers that test every change) time.
- Beginners can stop after the section on wrong cache hits. Engineers then get runs on Turborepo 2.11.10 and Nx 24.0.2. They show a test-server (staging) build given the live-site (production) bundle, a hit that restored no files, and a shallow clone (latest commit only) selecting everything.
What goes wrong when CI rebuilds the whole monorepo on every change?
Every pull request, a proposed change waiting for review, waits for work that has nothing to do with it. In CI, the continuous integration servers that build and test every change, a one-line fix still rebuilds and retests every other app and library. Monorepo build systems exist to stop that waste without letting broken code through.
Picture a common setup. One git repository holds a small online shop: a storefront web app, an orders API and a shared utils library that formats prices in rupees. The web app imports utils; the API does not. CI builds and tests all three on every pull request, and as the repository grows that reaches 40 minutes per run (made up). The setup is made up, but this slowdown is a common one in a growing monorepo.
In a setup like this, people start batching changes to dodge the wait. A flaky test in one package blocks merges in another. Much of the CI time goes into rebuilding code that did not change since yesterday.
A monorepo build system fixes this with three ideas. A graph tells it what depends on what. A cache lets it skip work it has already done. Affected-target selection tells it which parts a change can touch at all. This guide builds each idea on the shop example, then checks it on real tools.
Keeping git itself fast at this size, with huge histories and many contributors, is a separate problem with its own guide on monorepo version control. This guide is only about builds and tests.
Which words do you need before reading about monorepo builds?
Nine words carry the whole topic. Each one is used the same way for the rest of the guide.
- Package: one folder with its own package.json, such as apps/web. Nx calls it a project. Bazel splits code into smaller named targets declared in BUILD files.
- Task: one command for one package, such as build for apps/web. Turborepo and Nx both use this word.
- Dependency: a package that another package imports. In the shop, utils is a dependency of web.
- Dependent: the same link read the other way. The web app is a dependent of utils.
- Hash: a fixed-length fingerprint computed from data. Change one byte of the data and the fingerprint changes completely.
- Cache hit and cache miss: a hit means a saved result matched the fingerprint and was reused. A miss means the task had to run.
- Artifact or output: a file a task produces, such as the bundled dist folder of the web app.
- Base branch: the branch your change is compared against, usually main.
- Lockfile: the file that pins the exact versions of outside packages, such as package-lock.json for npm.
What do monorepo build systems actually do?
Monorepo build systems are task runners that understand the whole repository. They read how packages depend on each other, run tasks in the right order, skip tasks whose inputs have not changed, and select only tasks a change can affect. A plain script runs everything every time; these tools decide what to run.
The three well-known open-source tools do the same four jobs at different levels of detail. The table compares them on what this guide tested or sourced, as of 27 October 2026.
| Tool | Smallest unit it caches | Where the graph comes from | What goes into the cache key | How it picks affected work |
|---|---|---|---|---|
| Turborepo | One task in one package | package.json dependencies plus turbo.json | Tracked files in the package, turbo.json, lockfile entries, declared env vars, dependency hashes | --affected, which equals --filter=...[main...HEAD] |
| Nx | One task in one project | package.json dependencies in our run, plus nx.json | Project and dependency files, config, external versions, runtime values, CLI arguments | nx affected, comparing main with your working files |
| Bazel | One action, such as one compiler call | BUILD files that declare every target | Hash of the action: the command's hash plus the input file tree's hash | A reverse dependency query, rdeps, listing everything that depends on a target |
How does the build graph decide what runs and in what order?
The build graph is a directed graph: each package is a box, and each arrow means needs this first. The tool starts every box with no unfinished arrows at once, up to its concurrency limit (Turborepo's default is 10 tasks, as of 27 October 2026). A box starts as soon as everything it needs has finished.
In Turborepo you describe the arrows once in turbo.json. The caret in ^build means my dependencies' build tasks must finish first. The outputs line lists the files a build produces, which matters for caching later.
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}We ran this on the shop repository with turbo 2.11.10 and npm workspaces, npm's built-in way to link the packages of one repository. Each build script was padded to take 1.5 seconds, standing in for a real compile. The utils and api builds started together, and web started only after utils finished.
The shop's build graph for turbo run build
Drag to exploreNotice that api never waits: no arrow links it to utils, so it builds alongside utils. Only web waits, because it imports utils. The chain utils then web sets the total time.
Diagram connections:
- turbo run build to packages/utils build (starts at once)
- turbo run build to apps/api build (starts at once)
- packages/utils build to apps/web build (web waits for utils)
- apps/web build to All tasks finished (finishes last)
- apps/api build to All tasks finished (finished in parallel)
- turbo run build: Reads turbo.json and package.json files
- packages/utils build: No dependencies, starts at once
- apps/api build: No dependencies, starts at once
- apps/web build: Imports utils, so waits for it
- All tasks finished: Total time is the longest chain, utils then web
Nx drew the same graph from the same package.json files. Its graph output listed web depending on utils, and api and utils depending on nothing. Bazel builds a finer graph: it parses every BUILD file into targets and the actions that produce them. Its unit is one compiler call, not one package.
The graph already saves time, because independent packages run in parallel. The longest chain of arrows, here utils then web, sets the minimum time. Nx 24.0.2 prints this as the critical path at the end of each run.
How does content-addressed action caching work?
The tool builds a key from everything that can change a task's output, looks that key up, and either restores the saved output or runs the task and saves it. Content-addressed means data is stored and found by a hash of its content, not by a file name or a date. Two machines with identical inputs compute the same key.
Strictly, the key is computed from the inputs. Bazel then stores each output file under the hash of its own bytes. Turborepo and Nx keep each task's outputs together under the task's key; our .turbo/cache held one archive per hash.
The key usually covers four things. The command and the tool configuration. The contents of every input file. The values of declared environment variables. And the hashes of the tasks it depends on, which is why a change in utils reaches web.
Bazel's open Remote Execution API spells this out. It uses the word digest for a hash plus the data's size. An action is identified by the digest of its encoded form, which includes the digests of the command and of the whole input file tree. The action cache maps that digest to a result. The content-addressable store, or CAS, keeps each output file under the hash of its own bytes.
Think of a tailor's order book. Each finished order is filed under the exact measurements, fabric and design. When a new order matches an old entry exactly, the tailor hands over a copy of the finished shirt instead of stitching again. Measurements, fabric and design are the inputs, the entry number is the hash, and the shirt is the output.
Where the picture breaks: a tailor cannot copy a shirt for free, but a cache copies outputs at almost no cost. And the tailor only checks what was written in the book. If your arm grew but nobody measured it, you get the old shirt. That is exactly how wrong cache hits happen.
The whole mechanism fits in a short Python script. It runs a shell command, keys it by command, declared environment and input file contents, and keeps outputs in a dictionary that plays the CAS. The build reads two files but declares only one.
import hashlib, json, os, subprocess, tempfile
cas, action_cache = {}, {} # cas maps a digest to bytes; action_cache maps a key to an output digest
def digest(data):
return hashlib.sha256(data).hexdigest()
def run_action(cmd, inputs, env):
"""cmd: list of args, inputs: list of file paths, env: dict of declared env vars."""
key_parts = {"cmd": cmd, "env": sorted(env.items()),
"inputs": {p: digest(open(p, "rb").read()) for p in sorted(inputs)}}
key = digest(json.dumps(key_parts, sort_keys=True).encode())
if key in action_cache:
return "HIT", cas[action_cache[key]].split()
out = subprocess.run(cmd, capture_output=True, env={**env, "PATH": os.environ["PATH"]}).stdout
cas[digest(out)] = out # store the bytes by their own hash
action_cache[key] = digest(out) # remember: this action made those bytes
return "MISS", out.split()
d = tempfile.mkdtemp()
src, extra = os.path.join(d, "price.txt"), os.path.join(d, "tax.txt")
open(src, "w").write("499\n"); open(extra, "w").write("18\n")
build = ["sh", "-c", f"cat {src} {extra}"] # reads tax.txt, but we only declare price.txt
print(run_action(build, [src], {"MODE": "prod"})) # MISS: first time
print(run_action(build, [src], {"MODE": "prod"})) # HIT: same key
os.utime(src, (0, 0)) # change only the timestamp
print(run_action(build, [src], {"MODE": "prod"})) # HIT: content, not time, is hashed
print(run_action(build, [src], {"MODE": "dev"})) # MISS: declared env is in the key
open(extra, "w").write("28\n") # change the UNDECLARED input
print(run_action(build, [src], {"MODE": "prod"})) # HIT with stale output: 18, not 28On Python 3.13.17 the five calls printed MISS, HIT, HIT, MISS and HIT. The last line still printed 499 and 18, not 28. The cache did exactly what it was told, and the answer was stale.
What just happened? Changing only the timestamp kept the key, because time is not hashed. Changing the declared MODE value made a new key. Changing an undeclared file changed the real output but not the key, so the cache served old bytes.
What the real tool did on the shop repository
We repeated the same moves with turbo 2.11.10 on the shop repository. Every row below is from one scripted run, saved with its output.
| Change made | utils | web | api | Why |
|---|---|---|---|---|
| Same build run again | hit | hit | hit | Same hashes; the run took 10 ms and printed FULL TURBO |
| Only the timestamp of utils/src.js changed | hit | hit | hit | File contents are hashed, not modification times |
| One line added to utils/src.js | miss | miss | hit | web's key includes the hash of utils, so it changes too |
| That line removed again | hit | hit | hit | The old fingerprints were still in the cache |
| apps/web/dist deleted, build run again | hit | hit | hit | Declared outputs were restored from .turbo/cache |
You can see the key's ingredients yourself. Run turbo run build with --dry=json, a dry run that plans the tasks without running them. Read the inputs and hash fields of each task. In our run, each input file was listed with its git blob hash, the ID git itself gives a file's contents. It matched what git hash-object printed.
A declared environment variable appeared only as a SHA-256 hash of its value, so the value itself was not printed. This output format is undocumented as of 27 October 2026; pin your version before you script against it.
Why do wrong cache hits happen, and how do you prevent them?
A wrong cache hit happens when something changed the output but was not part of the key. The tool restores an old result that looks perfectly valid. The usual causes are environment variables, outputs nobody listed, and inputs from outside the repository.
Environment variables that change the output
The shop's API build bakes in an API_URL environment variable. We built it for production, then for staging, with no turbo.json entry for that variable. Turborepo's default strict mode passes a task only the variables declared in turbo.json, plus a few such as PATH. So the build saw nothing and wrote undefined.
That run was also a cache hit, because nothing in the key had changed. Turborepo's docs warn about this: tasks that handle a missing variable quietly can produce unintended cache hits instead of failing.
Loose mode, set with --env-mode=loose, passes every variable but still hashes only the declared ones. Our production build ran first and was cached. The staging build then hit that entry and restored a bundle pointing at the production URL.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL"]
}
}
}Bazel has the same rule in its own words. Only environment variables passed with --action_env become part of an action's definition, according to its remote caching docs. Anything else your compiler reads from the environment is invisible to the key.
Outputs you forgot to list
Turborepo caches only files named in outputs; its configuration reference says that omitting the key caches nothing. We removed the outputs line, built the API, deleted its dist folder and built again. Turborepo printed cache hit and replayed the logs, but dist was not restored.
This is the most confusing failure for beginners, because the log says success. The fix is to list every folder a task writes, then check by deleting it and running the build again.
Inputs from outside the repository
Bazel's docs list the known traps. Bazel does not track tools installed outside the workspace. An input file modified during a build might upload invalid results to the remote cache.
Bazel defines a hermetic build as one that returns the same output for the same source and configuration by isolating itself from the host machine. Its docs name common leaks: timestamps or build IDs written into outputs, system binaries that differ between machines, and writing to the source tree during a build.
If you are new to this topic, you can stop here. You now know what the graph, the fingerprint and the cache do, and why a cache can be wrong. The rest of the guide is for engineers running CI on a real monorepo.
How does remote caching share build results across CI machines?
A remote cache is a shared server holding the same hash-to-output entries as the local cache. Each machine asks it before running a task. A machine that misses builds the task and uploads the result. Every later machine with the same fingerprint downloads it.
Turborepo's Remote Cache API is plain HTTP, and its docs say any server that meets the published specification can act as one. To test it, we wrote a small Node server that keeps artifacts in memory. We pointed turbo at it with the TURBO_API, TURBO_TOKEN and TURBO_TEAM environment variables.
Then we cloned the shop repository twice, standing in for two fresh CI machines. Machine 1 missed all three tasks and uploaded three artifacts of about 250 bytes each. Machine 2 had no local .turbo folder at all, yet all three tasks were hits and dist came back.
GET /v8/artifacts/207936545a6dde6c 404 machine 1: not cached yet
PUT /v8/artifacts/207936545a6dde6c 202 machine 1: uploads after building
GET /v8/artifacts/207936545a6dde6c 200 machine 2: downloads, no buildOne task with a remote cache
Drag to exploreNotice that the cache is keyed only by the hash. Whatever is stored under a hash is what every later machine receives, which is why write access matters.
Diagram connections:
- CI job hashes the task to Hash in remote cache? (look up by hash)
- Hash in remote cache? to Run the task (No: cache miss)
- Hash in remote cache? to Download the artifact (Yes: cache hit)
- Run the task to Remote cache server (PUT artifact by hash)
- Remote cache server to Download the artifact (serves the bytes)
- Download the artifact to Outputs and logs on disk (unpack outputs)
- Run the task to Outputs and logs on disk (outputs already written)
- CI job hashes the task: Inputs, command, env, dependency hashes
- Hash in remote cache?: One GET request by hash
- Run the task: Cache miss: do the real work
- Remote cache server: Stores artifacts by hash
- Download the artifact: Cache hit: no build needed
- Outputs and logs on disk: Same files either way
Bazel and Nx work the same way with different protocols. Bazel takes --remote_cache with an HTTP or gRPC address, gRPC being a remote-call protocol, or --disk_cache for a local folder. Nx publishes an OpenAPI description, a machine-readable spec of an HTTP API, for self-hosted servers. It uses PUT and GET on /v1/cache/HASH.
Who should be allowed to write to the cache?
Only builds you trust. Bazel's remote caching docs advise taking care over who can write and suggest letting only your CI system write. The reason is the diagram above: whatever is stored under a hash is what everyone gets. Nx's self-hosted spec even refuses to overwrite an existing entry, so the first upload stays.
CVE-2025-36852, a public ID for a known security flaw published on 10 June 2025, describes this risk for remote caches kept in cloud storage buckets. Its record lists Nx's own cache plugins for S3, GCS and Azure storage and shared file systems, plus community plugins such as MinIO. Artifacts built in untrusted places, such as pull-request jobs, could poison the cache read by protected branches, the branches only reviewed code reaches. The record says checksums and access controls do not help, because the bad artifact is built before they apply.
Let trusted builds write
Give write access only to CI jobs on protected branches, such as main. Developers' laptops and pull-request jobs read.
Make pull-request jobs read-only
Bazel: --remote_upload_local_results=false. Turborepo: --cache=local:rw,remote:r, which printed Remote cache is read-only, skipping upload in our run. Nx self-hosted cache: give pull requests a read-only token. Its spec returns 403 when such a token tries to write.
Sign what you upload
Turborepo can sign artifacts with HMAC-SHA256, a signature made with a shared secret, set in TURBO_REMOTE_CACHE_SIGNATURE_KEY. Artifacts that fail verification count as misses. Keep the key away from untrusted jobs, or signing proves nothing.
Keep untrusted results apart
Use a separate cache or namespace for untrusted builds. The Remote Execution API has a salt field for exactly this: it places an action in a separate cache namespace.
Keep secrets out of logs
Turborepo stores and replays terminal output as part of the artifact. Anything a task prints is shared with every machine that hits that entry.
How does affected-target selection pick what to build and test?
Affected-target selection diffs your branch against a base, maps each changed file to the package that owns it, and adds every package that depends on those packages. Everything else is left out of the run. In the shop, an edit to utils selects utils and web, but never api.
# Turborepo 2.11.10: same as --filter=...[main...HEAD]
npx turbo run build --affected
# Nx 24.0.2: base defaults to main
npx nx affected -t build --base=main
# pnpm 12.15.0: changed packages plus their dependents
pnpm --filter "...[main]" run build
# Bazel, not run for this guide: reverse dependencies of utils
bazel query 'rdeps(//..., //packages/utils:utils)'The three dots matter in Turborepo and pnpm. Three dots before a selector mean the package and its dependents. Inside the brackets, main...HEAD is a git range, a different meaning. In pnpm, three dots after it mean the package and its dependencies instead, so [main]... picked only utils.
| Change on the branch | Turborepo --affected | Nx affected | pnpm ...[main] |
|---|---|---|---|
| Edit in packages/utils | utils, web | utils, web | utils, web |
| Edit in apps/api | api | api | - |
| New README.md at the root | nothing | - | - |
| Edit in turbo.json | all three | - | - |
| Uncommitted edit, nothing committed | counted | counted | - |
Turborepo's run reference does not say whether uncommitted files count. In our runs they did. Nx's docs say its default head is your current file system, and pnpm's docs say uncommitted changes are included.
Why shallow CI clones break affected selection
Affected selection needs the history between the base and your commit. A shallow clone, a checkout that fetches only the latest commits, does not have it. We cloned the shop's feature branch with depth 1 and ran turbo run build --affected.
Turborepo warned that it was unable to detect the git range and assumed all files had changed. All three packages were selected. A full clone with TURBO_SCM_BASE set to origin/main selected only utils and web again.
Nx's docs give the same warning for GitHub: without full git history there is no base commit to compare against. They suggest fetching full history and setting the base and head commits explicitly.
Which base should a pull request compare against?
Turborepo compares main...HEAD by default; the three dots mean git compares from the point where your branch left main. In our run, a later api commit on main was not selected for a branch that changed only utils. TURBO_SCM_BASE and TURBO_SCM_HEAD override the range. pnpm, since version 12.7.0, compares against the merge base too.
Nx recommends setting the base to the last commit on main whose CI run succeeded. That way a failure on main is retried by the next change instead of being skipped. Nx's docs also say a lock file change affects every task by default.
Affected selection and caching work together. Selection trims the list of tasks before anything runs; the cache skips unchanged tasks that are still on the list. Nx's docs warn that an undeclared input or output can make it miss a task, so a regular full build on main is a sensible safety net.
How do you keep monorepo CI under 10 minutes?
Make the cache correct, share it, shrink the task list, then spread what is left across machines. Ten minutes is a target, not a promise; measure each step on your own repository. The order matters, because a fast but wrong cache ships bad builds.
Measure where the time goes
Turborepo's --summarize writes a JSON file in .turbo/runs with each task's timing, hash and cache status. Nx 24.0.2 prints the run duration, cache hit rate and critical path after each run.
Make cache hits trustworthy
Declare environment variables and outputs. Run the same build twice and expect every task to hit. Use a dry run to read the inputs of any task that misses unexpectedly.
Share results through a remote cache
Let main-branch CI write and everything else read. A pull request then reuses whatever main already built for unchanged packages.
Run only affected tasks on pull requests
Fetch enough git history, set the base explicitly, and use --affected or nx affected. Check the log for a warning that all files changed.
Cache the folder the tool really uses
If your CI saves a local cache between runs, point it at the current default folder. Nx 24 moved it, as the callout below explains.
Spread the remaining work
Bazel's remote execution sends actions to a pool of workers over the same open protocol as its cache. Split slow test suites so the critical path shrinks.
Keep a full build as a safety net
Run every task on main on a schedule. It catches anything the graph or the affected logic missed.
Bazel also caches test results. Its user manual says that, by default, a test reruns only in four cases. The test or its dependencies changed, it failed, it is marked external, or you asked for repeated runs.
Bazel vs Nx vs Turborepo: which one fits your repository?
Pick by how fine-grained and how strict your builds need to be. In our runs, Turborepo and Nx cached whole package tasks in a JavaScript workspace, configured from files it already had. Bazel caches individual actions, supports many languages, and asks you to declare every target in BUILD files.
Turborepo was the smallest step for our existing npm workspace: one turbo.json file. Any tracked file change inside a package reruns that package's task, which is coarse but easy to reason about.
Nx works at the same project level and adds tooling around the graph, such as nx graph and affected selection with a configurable base. Its cache location and lock file rules differ from Turborepo's, as shown above.
Bazel's action-level key means one changed source file reruns the actions that read it, plus later actions whose inputs then change. Its remote cache and remote execution use the open Remote Execution API. Other build tools speak it too, including Buck2, Pants and Please, and servers such as Buildbarn, Buildfarm and BuildGrid implement it.
In our judgement, a team should move to action-level builds only when package-level caching has been made correct and is still too slow. That is an opinion, not a measurement; test it on your own repository first.
How do you explain monorepo build systems in a system design interview?
Frame it as a correctness problem first and a speed problem second. Interviewers want to hear what goes into the cache key and what happens when it is wrong.
State the problem
Many packages share one repository. Every change rebuilds and retests everything, so CI time grows with the repository, not with the change.
Draw the build graph
Packages are boxes, imports are arrows. Tasks run in dependency order, and independent ones run in parallel. The longest chain sets the minimum time.
Define the cache key
Hash the command, input file contents, declared environment variables and dependency hashes. Store outputs by content hash, and map each key to its outputs.
Share the cache safely
Add a remote cache. Only trusted main-branch builds write; pull requests read. Sign artifacts and keep untrusted results in a separate namespace.
Shrink the work
Diff against a base commit, map files to packages, add their dependents. Fetch enough history, and run a full build on main as a safety net.
Name the failure modes
Undeclared inputs give stale hits, unlisted outputs give empty hits, shallow clones select everything, and open write access allows cache poisoning.
Questions people ask
- Is a monorepo build system the same thing as a monorepo?
- No. A monorepo is one repository holding many apps and libraries. A monorepo build system is the tool that builds and tests that repository efficiently. You can keep a monorepo with plain scripts that run everything. The build system adds a dependency graph, a cache and affected-target selection, so each change runs only the work it needs.
- Do I need Bazel for a large monorepo?
- Not necessarily. Turborepo and Nx cache whole package tasks; in our tests each needed one config file in a JavaScript workspace. Bazel caches individual actions across many languages and supports remote execution, but needs BUILD files for every target. Start by making package-level caching correct, measure, and consider action-level builds only if that is still too slow.
- What is the difference between a local cache and a remote cache?
- A local cache lives on one machine, such as .turbo/cache for Turborepo, and helps only that machine. A remote cache is a shared server with the same hash-to-output entries. In our test, a fresh CI clone with no local cache got cache hits for all three tasks because another clone had uploaded them first.
- Why does my build say cache hit but the output files are missing?
- Usually because the task's outputs were never declared. Turborepo caches only files listed in outputs and still replays logs on a hit. In our run on turbo 2.11.10, removing the outputs line produced a cache hit with no dist folder restored. List every folder the task writes, delete it and rebuild to check.
- Is affected-target selection safe to use on its own?
- It is safe only when the graph and git history are complete. It can miss tasks whose inputs are undeclared, and a shallow clone makes Turborepo assume everything changed. Fetch enough history, set the base commit explicitly, and keep a scheduled full build on main to catch anything the selection missed.
- Does a content-addressed cache use file timestamps?
- No, it hashes file contents. In our Turborepo 2.11.10 run, touching a file to change only its timestamp kept every task a cache hit. Editing one line missed, and reverting that line hit the old entry again. This is also why two machines with identical files compute identical keys and can share a remote cache.
Sources and further reading
- Bazel docs: Remote caching (action cache, CAS, who should write, known issues)
- Bazel docs: Intro to Bazel (languages, BUILD files, targets)
- Bazel docs: Hermeticity
- Bazel docs: Artifact-based build systems
- Bazel docs: Query guide (deps and rdeps)
- Bazel docs: Remote execution overview
- Bazel docs: User manual (--cache_test_results, --action_env)
- Remote Execution API: remote_execution.proto (Action, ActionCache, CAS, Digest, salt)
- Remote Execution API README: client and server implementations
- Turborepo docs: Caching
- Turborepo docs: Remote Caching (self-hosting, artifact signing)
- Turborepo docs: Using environment variables (strict and loose modes)
- Turborepo docs: run command reference (--affected, --dry, --summarize, --cache)
- Turborepo docs: turbo.json configuration reference
- Turborepo Remote Cache API specification (OpenAPI JSON)
- Nx docs: How caching works
- Nx docs: Run only tasks affected by a change
- Nx docs: Change the cache location
- Nx docs: Self-hosted remote cache (OpenAPI)
- pnpm docs: Filtering (changed since a commit)
- CVE record JSON for CVE-2025-36852 (description, date, affected packages)
- OSV: CVE-2025-36852, build cache poisoning via untrusted pull requests