Content-Addressed Caching - Cache Keys, Remote Cache, Remote Execution & Poisoning
What goes into a cache key; undeclared env var wrong-cache-hit demo and the strict-env fix (runnable); local vs remote cache vs remote execution (REAPI CAS + action cache); constructive vs deep traces; cache economics and the critical-path floor (runnable); poisoning table incl. untrusted writers; caching decision chart.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What is a cache key?
Answer
A hash of everything that could affect a task's output.
L2
Name four input classes that belong in a key.
Answer
Source files, the task definition, dependency hashes and lockfile entries, plus toolchain, env vars, platform and arguments.
L3
How did the preview URL reach production in the demo?
Answer
API_URL was read but not in the key, so the production build hit the preview entry.
L4
Remote cache vs remote execution?
Answer
A remote cache shares results. Remote execution ships the action and inputs to workers.
L5
What are the three REAPI services?
Answer
Content-addressable storage, the action cache and the execution service.
L6
When is caching slower than running?
Answer
When the task is faster than a round trip, or the output is too large to transfer quickly.
L7
How do you secure a shared build cache?
Answer
Only trusted protected-branch CI writes, PR and fork jobs read-only, authenticated clients and verified artifacts.
Failure modes
An undeclared env var ships the preview bundle to production
API_URL was baked into the bundle but missing from the key, so the production build hit the preview entry.
Untrusted writers poison the remote cache
Any CI job, including forks, can upload bad artifacts under good keys if writes are not restricted.
Host toolchain leaks across runners
Different compilers on different runners make hits from one runner break on another.
Misconceptions
Strict env mode guarantees correct caching.
A task that tolerates a missing variable still succeeds with the wrong value.
Caching every task is always faster.
Tiny tasks and huge artifacts can cost more in lookup and transfer than they save.
A remote cache is artifact storage for releases.
Evictions are normal. Publish releases to a registry by digest.
Interviewer traps
Letting pull request jobs write to the shared cache.
Treat cache write access like deploy access. PR and fork jobs should read only.
Claiming remote execution removes the build floor.
Remote execution cannot beat the critical path.
Design scenario
Same prompt for every reader.
Requirements
Shared hits between CI and developers, no preview config in production, and no way for a fork to poison the cache.
Failure assumptions
- Some tasks read env vars nobody declared.
- Fork PRs run CI.
- Runners have different host compilers.
Constraints
- Secrets must not appear in keys, outputs or cached logs.
- Production builds must miss when API_URL changes.
Prompt
Add a remote cache to a monorepo whose web bundle bakes in API_URL and whose CI runs on forks.
API
Which env vars does each task declare, and which pass through?
Data
What exactly goes into the key for the production bundle?
Architecture
Who can write to the cache, and where does remote execution fit?
Overview
A build cache is a key-value store where the key is a hash of everything that could affect a task's output and the value is the output (files plus logs). Get the key right and a result computed once on any laptop or CI runner can be reused everywhere: that is local caching, remote caching, and, one step further, remote execution, where the build sends the action itself to a worker farm. Get the key wrong and the cache becomes a correctness bug: it confidently serves an output that was built from different inputs. Everything on this page is about what goes into the key, what it costs to look things up, and how hidden inputs and non-determinism leak in.
What goes into a cache key
| Input class | Examples | Why it matters | How tools capture it |
|---|---|---|---|
| Source files | src/**, configs, fixtures | Obvious | File content hashes (git object hashes or SHA-256) |
| Task definition | The command, flags, outputs config | Same files, different command, different result | Turborepo hashes the resolved task definition; Bazel hashes the action's command line |
| Dependency outputs | Hashes of upstream tasks or their outputs | Changes must propagate | Task hash includes dependency task hashes; Bazel uses input file digests |
| External dependency versions | Lockfile entries | A minor bump can change output | Turborepo and Nx include lockfile entries relevant to the package |
| Toolchain | Compiler, Node, JDK version | Different compiler, different bytes | Bazel and Nix treat toolchains as declared inputs; task runners rely on you to pin them |
| Environment variables | API_URL, NODE_ENV, NEXT_PUBLIC_* | Often baked into bundles | Turborepo env/globalEnv; Nx env inputs; Bazel passes only declared env to actions |
| Platform | OS, CPU architecture | Native code and some tools differ | Nx includes runtime values such as OS and arch; Bazel platforms; Nix system |
| Arguments | -- --sourcemap | Changes output | Both Nx and Turborepo include passthrough args in the hash |
The cache is only as good as this list. Anything a task reads that is not in the key is an undeclared input, and it can turn a cache hit into a wrong answer.
Decisions
- 1
Step 1: collect declared inputs (files, task config, lockfile, env, toolchain)
- nextStep 2: hash them into a task key
- 2
Step 2: hash them into a task key
- nextStep 3: key in local cache?
- ?
Step 3: key in local cache?
- nextStep 4a: restore outputs and replay logs
- nextStep 4b: key in remote cache?
- 4
Step 4a: restore outputs and replay logs
- ?
Step 4b: key in remote cache?
- nextStep 4a: restore outputs and replay logs
- nextStep 5: execute locally or on a remote worker
- 6
Step 5: execute locally or on a remote worker
- nextStep 6: upload outputs keyed by the task hash (content-addressed blobs)
- nextFailure path: task read an undeclared env var or file
- 7
Step 6: upload outputs keyed by the task hash (content-addressed blobs)
- 8
Failure path: task read an undeclared env var or file
- nextSame key now maps to output built from different inputs, later hits are wrong
- 9
Same key now maps to output built from different inputs, later hits are wrong
- nextFix: declare the input, filter env (strict mode), sandbox, or purge the poisoned entries
- 10
Fix: declare the input, filter env (strict mode), sandbox, or purge the poisoned entries
Lesson map
Content-Addressed Caching - Cache Keys, Remote Cache, Remote Execution & Poisoning
What goes into a cache key; undeclared env var wrong-cache-hit demo and the strict-env fix (runnable); local vs remote cache vs remote execution (REAPI CAS + action cache); constructive vs deep traces; cache economics and the critical-path floor (runnable); poisoning table incl. untrusted writers; caching decision chart.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1: collect declared inputs (files, task config, lockfile, env, toolchain)"] b["Step 2: hash them into a task key"] c["Step 3: key in local cache?"] r["Step 4a: restore outputs and replay logs"] d["Step 4b: key in remote cache?"] e["Step 5: execute locally or on a remote worker"] f["Step 6: upload outputs keyed by the task hash (content-addressed blobs)"] l["Failure path: task read an undeclared env var or file"] p["Same key now maps to output built from different inputs, later hits are wrong"] q["Fix: declare the input, filter env (strict mode), sandbox, or purge the poisoned entries"] a -->|continues| b b -->|continues| c c -->|continues| r c -->|continues| d d -->|continues| r d -->|continues| e e -->|continues| f e -->|continues| l l -->|continues| p p -->|continues| q
What goes into the cache key?
Prefer
Every input the task reads, declared
Sources, task definition, dependency hashes, lockfile entries, toolchain, env vars, platform and arguments.
- A changed API_URL produces a new key and a miss.
- Strict env filtering hides undeclared variables from the task.
- Only trusted CI writes the shared entries.
Alternative
Files only, loose environment
The key ignores what the task reads from the environment, so the cache serves outputs built from other inputs.
- The production build hits the preview entry.
- Hits from one runner break on another toolchain.
- Any writer can upload bad artifacts under good keys.
From inputs to a safe shared result
Diagram 1 condensed. The poisoning table below lists every way the key can lie.
- 1
Collect declared inputs
Files, command, dependency hashes, lockfile entries, toolchain, env, platform and args. - 2
Hash into a key
The key names the result. Anything left out is an undeclared input. - 3
Look up local, then remote
A hit restores outputs and logs. A miss runs the task. - 4
Write only from trusted CI
Untrusted writers and undeclared inputs are how caches get poisoned.
Local cache, remote cache, remote execution
| Level | What is shared | What you need | Gains | Costs and risks |
|---|---|---|---|---|
| Local cache | Results on one machine (.turbo/cache, Nx local cache, Bazel output base and disk cache) | Nothing extra | Fast reruns, branch switching | Disk usage; does not help CI or teammates |
| Remote cache | Results across machines, keyed by the same hashes | A shared store (Vercel Remote Cache, Nx Cloud or self-hosted, Bazel remote cache, Gradle build cache node, Nix binary cache) | CI and teammates reuse each other's work | Network round trips; trust (who may write?); poisoning if keys are incomplete |
| Remote execution | The work itself runs on a worker pool; inputs and outputs live in a content-addressable store | A Remote Execution API server (Buildbarn, BuildBuddy, EngFlow, Buildfarm and others) and hermetic actions | Massive parallelism; laptops stop compiling | Requires fully declared, sandbox-safe actions; operating a farm |
The Remote Execution API (REAPI), defined in the bazelbuild/remote-apis repository, splits the job into three services: a content-addressable storage (CAS) keyed by blob digest, an action cache mapping an action digest to its result, and an execution service that runs actions. Bazel, Buck2, Pants and several other clients speak it, which is why "remote cache" and "remote execution" are portable across vendors in that ecosystem. Turborepo and Nx use their own HTTP cache protocols instead. Nix uses its own model: substituters (binary caches such as cache.nixos.org) serve store paths, and remote builders run derivations over SSH.
Build Systems a la Carte names the underlying idea: a constructive trace records input hashes together with the output, so anyone who computes the same input hashes can fetch the output instead of rebuilding. A deep constructive trace (the paper's model of Nix and Buck) keys only on terminal inputs, which saves round trips but requires deterministic tasks: the paper's "Frankenbuild" example shows a non-deterministic step producing a downloaded result inconsistent with a locally rebuilt dependency.
How caches get poisoned or go wrong
| Problem | Example | Symptom | Defense |
|---|---|---|---|
| Undeclared env var | Bundle bakes API_URL not listed in the key | Wrong config ships, often preview to prod | Declare env per task; strict env filtering; lint for env usage |
| Undeclared file | Script reads ../shared/config.json | Change in that file never invalidates | Sandbox (Bazel, Nix); explicit inputs; dependency inference |
| Host toolchain leak | /usr/bin/gcc differs between runners | Hits from one runner break on another | Hermetic or pinned toolchains; include versions in the key |
| Non-determinism | Timestamps, random IDs, unordered maps in output | Different bytes per run, low hit rate downstream | Reproducible-build hygiene (next page): fixed epoch, sorted output |
| Untrusted writers | Any CI job, including forks, can write the shared cache | An attacker or a broken job uploads bad artifacts under good keys | Only trusted, protected-branch CI writes; PR jobs read-only; signed or verified artifacts |
| Absolute paths in outputs | Source maps or binaries embed /home/ajay/repo | Restored artifacts point to another checkout | Path remapping flags; relative paths; avoid caching such outputs |
The untrusted writer row is the security version of the correctness problem: a cache keyed by inputs trusts whoever uploaded the output. Treat remote-cache write access like deploy access. Nix approaches this with signatures: by default, non-content-addressed paths copied from a binary cache must be signed by a trusted key (require-sigs), while content-addressed paths are self-verifying because their path is the hash of their content.
Runnable: an undeclared env var produces a wrong cache hit, and the fix
The task bakes API_URL into a bundle, as front-end frameworks do with public env prefixes. Version A never put API_URL in the key, so the production build restores the preview bundle. Version B declares it and filters the task's environment to declared variables only. Version C shows why filtering matters: forgetting the declaration now produces a visibly broken build instead of a silently wrong one.
"""Content-addressed task cache: an undeclared env var produces a WRONG cache hit.
The "build" bakes API_URL into the bundle (like NEXT_PUBLIC_* or VITE_* variables).
Version A hashes only declared files, so a production build restores the preview bundle.
Version B fixes it two ways: the env var is part of the key, and the task only sees
declared variables (strict env filtering), so a missing declaration fails loudly.
"""
import hashlib, json
def key_of(parts: dict) -> str:
return hashlib.sha256(json.dumps(parts, sort_keys=True).encode()).hexdigest()[:12]
files = {"src/app.ts": "fetch(API_URL + '/orders')", "package-lock.json": "lock-v1"}
toolchain = {"node": "22.14.0", "os": "linux-x64"}
def build_task(env: dict) -> str:
# The task reads API_URL from its environment, whether or not anyone declared it.
return f"bundle(api={env.get('API_URL', '<unset>')})"
def run(cache, env, declared_env, strict, label):
parts = {"task": "build", "files": {k: key_of({"c": v}) for k, v in files.items()},
"toolchain": toolchain,
"env": {k: env.get(k) for k in sorted(declared_env)}}
k = key_of(parts)
if k in cache:
print(f"{label:28} key={k} HIT -> {cache[k]}")
return cache[k]
task_env = {n: env[n] for n in declared_env if n in env} if strict else dict(env)
out = build_task(task_env)
cache[k] = out
print(f"{label:28} key={k} MISS -> {out}")
return out
preview = {"API_URL": "https://preview.api.example", "HOME": "/home/ci"}
prod = {"API_URL": "https://api.example", "HOME": "/home/ci"}
print("A) loose: env var used but not declared in the key")
cache = {}
run(cache, preview, declared_env=[], strict=False, label="preview build")
out = run(cache, prod, declared_env=[], strict=False, label="production build")
print(" shipped to production:", out, "<- WRONG, preview URL")
print("B) strict + declared: API_URL is part of the cache key")
cache = {}
run(cache, preview, declared_env=["API_URL"], strict=True, label="preview build")
run(cache, prod, declared_env=["API_URL"], strict=True, label="production build")
run(cache, prod, declared_env=["API_URL"], strict=True, label="production rebuild")
print("C) strict but forgot to declare: the task sees nothing, so the bug is visible")
cache = {}
run(cache, prod, declared_env=[], strict=True, label="production build")Output:
A) loose: env var used but not declared in the key
preview build key=ae77a3b61f5d MISS -> bundle(api=https://preview.api.example)
production build key=ae77a3b61f5d HIT -> bundle(api=https://preview.api.example)
shipped to production: bundle(api=https://preview.api.example) <- WRONG, preview URL
B) strict + declared: API_URL is part of the cache key
preview build key=a9d96fecc8b1 MISS -> bundle(api=https://preview.api.example)
production build key=81b70cd5321b MISS -> bundle(api=https://api.example)
production rebuild key=81b70cd5321b HIT -> bundle(api=https://api.example)
C) strict but forgot to declare: the task sees nothing, so the bug is visible
production build key=ae77a3b61f5d MISS -> bundle(api=<unset>)This is the failure Turborepo's documentation warns about in its environment-variables guide: in loose mode a changed MY_API_URL can still hit the cache and ship the preview value to production, which is why strict mode (filter task env to declared variables) is the default in Turborepo 2.x. Even strict mode is not a guarantee: a task that tolerates a missing variable will still succeed with the wrong value.
Runnable: cache economics and remote execution limits
Caching is not free. Every task pays hashing and a lookup, and a hit pays the download. The model below uses example numbers to show the three cases Turborepo's docs call out (very fast tasks, enormous artifacts) and the floor that remote execution cannot beat: the critical path.
// Cache and remote-execution economics with illustrative numbers (not benchmarks).
// Expected task time with a cache = hashing + lookup + (hit ? restore : run + upload).
// Caching loses when the task is cheaper than a network round trip or the artifact is huge.
type TaskProfile = { name: string; runSec: number; artifactMB: number };
const lookupSec = 0.15; // one round trip to a remote cache (example value)
const hashSec = 0.05; // hashing declared inputs (example value)
const mbPerSec = 50; // download/upload throughput (example value)
function expected(t: TaskProfile, hitRate: number) {
const transfer = t.artifactMB / mbPerSec;
const hit = hashSec + lookupSec + transfer; // restore outputs
const miss = hashSec + lookupSec + t.runSec + transfer; // run, then upload
return hitRate * hit + (1 - hitRate) * miss;
}
const profiles: TaskProfile[] = [
{ name: "typecheck pkg", runSec: 40, artifactMB: 2 },
{ name: "lint tiny pkg", runSec: 0.1, artifactMB: 0.01 },
{ name: "docker image", runSec: 25, artifactMB: 1500 },
];
console.log("task no-cache hit=50% hit=90% verdict");
for (const p of profiles) {
const e50 = expected(p, 0.5);
const e90 = expected(p, 0.9);
const verdict = e90 < p.runSec ? "cache it" : "do not cache remotely";
console.log(
`${p.name.padEnd(15)} ${p.runSec.toFixed(1).padStart(7)}s ${e50.toFixed(1).padStart(7)}s ${e90.toFixed(1).padStart(7)}s ${verdict}`,
);
}
// Remote execution: many workers help only until you hit the critical path.
const actions = 400; // independent compile actions (example)
const perAction = 3; // seconds each (example)
const criticalPath = 30; // longest dependency chain in seconds (example)
for (const workers of [8, 64, 512]) {
const t = Math.max(criticalPath, (actions * perAction) / workers);
console.log(`remote execution with ${String(workers).padStart(3)} workers: about ${t.toFixed(0)}s (critical path floor ${criticalPath}s)`);
}Output:
task no-cache hit=50% hit=90% verdict
typecheck pkg 40.0s 20.2s 4.2s cache it
lint tiny pkg 0.1s 0.3s 0.2s do not cache remotely
docker image 25.0s 42.7s 32.7s do not cache remotely
remote execution with 8 workers: about 150s (critical path floor 30s)
remote execution with 64 workers: about 30s (critical path floor 30s)
remote execution with 512 workers: about 30s (critical path floor 30s)Expectedtask no-cache hit=50% hit=90% verdict typecheck pkg 40.0s 20.2s 4.2s cache it lint tiny pkg 0.1s 0.3s 0.2s do not cache remotely docker image 25.0s 42.7s 32.7s do not cache remotely remote execution with 8 workers: about 150s (critical path floor 30s) remote execution with 64 workers: about 30s (critical path floor 30s) remote execution with 512 workers: about 30s (critical path floor 30s)
Press Run. Snippets must be self-contained — no network, files, or native modules.
Two levers dominate real hit rates: key stability (do volatile inputs like timestamps, absolute paths or the full lockfile change every key?) and granularity (a shared package that changes daily invalidates every package-level key downstream).
Decision chart: should this task be cached, and where?
Decisions
- 1
A
- nextDo not cache yet: fix inputs or mark uncacheable
- nextStep 2: is it slower than a cache round trip?
- 2
Do not cache yet: fix inputs or mark uncacheable
- ?
Step 2: is it slower than a cache round trip?
- nextRun it every time, caching costs more than it saves
- nextStep 3: is the output small relative to rebuild time?
- 4
Run it every time, caching costs more than it saves
- ?
Step 3: is the output small relative to rebuild time?
- nextLocal cache only, or publish to a registry instead
- nextStep 4: do many machines run the same task?
- 6
Local cache only, or publish to a registry instead
- ?
Step 4: do many machines run the same task?
- nextLocal cache is enough
- nextStep 5: are actions hermetic and is the critical path short?
- 8
Local cache is enough
- ?
Step 5: are actions hermetic and is the critical path short?
- nextRemote cache plus remote execution
- nextRemote cache, writes from trusted CI only
- 10
Remote cache plus remote execution
- 11
Remote cache, writes from trusted CI only
What happens if you choose otherwise
- No remote cache: every CI job and developer recomputes the same results. Simple and safe, but CI time scales with repo size.
- Remote cache with loose env handling: fast and occasionally wrong in the worst way (wrong config in production).
- Remote execution without hermetic actions: actions that work locally fail remotely because the worker lacks an undeclared tool, which is actually a useful way to discover leaks.
- Caching everything, including Docker images and tiny lint tasks: network time can exceed compute time.
Pitfalls
- A cache hit that is "too fast to be true" after changing config is a signal to inspect the key (Turborepo
--summarize, Nx task hash details, Bazel execution logs). --forcestyle flags usually skip reads but still write; know which your tool does.- Evictions are normal; never treat the remote cache as artifact storage for releases. Publish releases to a registry by digest.
- Do not include secrets in keys or cached logs; declare them as pass-through env where the tool supports it, and keep them out of outputs.
Interview Q&A
What goes into a good cache key?
Answer
Hashes of source inputs, the task command and config, dependency outputs or hashes, external dependency versions from the lockfile, toolchain versions, relevant env vars, platform and arguments. Anything the task reads that could change its output.
What is cache poisoning in a build system?
Answer
A cache entry whose key does not describe the inputs that produced it, through an undeclared input, non-determinism or a malicious writer, so later builds with the same key get a wrong artifact.
Remote cache vs remote execution?
Answer
A remote cache shares results; you still run misses locally. Remote execution ships the action and its inputs to workers, which lets one build use hundreds of machines but requires every action to be fully declared and hermetic.
When is caching slower than running?
Answer
When the task is faster than a cache round trip, or its output is so large that transfer time exceeds rebuild time, or the hit rate is low because keys churn.
How would you secure a shared build cache?
Answer
Allow writes only from trusted CI on protected branches, make PR and fork jobs read-only, authenticate clients, keep secrets out of outputs and logs, and where possible verify artifacts by content hash or signature.
What does the Remote Execution API split the job into?
Answer
A content-addressable storage keyed by blob digest, an action cache mapping an action digest to its result, and an execution service that runs actions.
Constructive vs deep constructive traces?
Answer
A constructive trace stores input hashes with the output so anyone with the same hashes can fetch it. A deep constructive trace keys only on terminal inputs, which saves round trips but needs deterministic tasks.
Why is strict env mode not a complete guarantee?
Answer
It hides undeclared variables from the task, but a task that tolerates a missing variable still succeeds with the wrong value.
Check yourself
Open the cache summary for one task in your repo (Turborepo --summarize, Nx task hash details or a Bazel execution log). Find one input that should be in the key but is not, and one that churns the key for no reason.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Cache Invalidation — TTL vs Event-Driven vs Versioned Keys, CI Performance — Caching, Parallelism & Flaky Jobs, Artifacts & Registries — Digests, Provenance & Immutability, Secrets Threat Model — Leakage Paths, Side Channels & Audit Trails, Pipeline Anatomy — Stages, Gates, Environments & Promotion.