Remote Caching and Distributed Build Coordination
Remote caching turns a monorepo task — build, test, lint, type-check — into a content-addressed lookup: Turborepo hashes every input that can affect a task’s output, checks whether an artifact for that hash already exists in a shared store, and replays the cached output instead of re-running the task. The payoff is that a build computed once on a developer’s laptop is reused verbatim by CI and by every teammate, so a clean-clone CI run that would take eight minutes collapses to a forty-second restore. This guide covers how Turborepo computes those hashes, the wire protocol it speaks to a cache server, how to wire up Vercel Remote Cache and a self-hosted alternative, the turbo.json pipeline that defines task inputs and dependencies, and how to debug the inevitable “why did this miss?” investigations. For the incremental-compilation model that complements task-level caching, see esbuild & Turbopack Workflows before treating the remote cache as a black box — a cache you cannot explain is a cache you cannot trust.
The problem exists because a monorepo multiplies redundant work. A twelve-package workspace where each pull request touches two packages still recompiles all twelve on every CI run, because a fresh CI runner has no memory of the last build. Without a shared cache, the only state that survives between runs is whatever the CI provider’s own path-based cache happens to restore — node_modules, maybe a .next/cache directory — and that cache is keyed by a coarse lockfile hash, not by the actual inputs of each task. The result is the familiar failure mode: a one-line change to a leaf package triggers a full-workspace rebuild, CI queues back up, and the feedback loop that should take under a minute takes ten. Remote caching replaces the coarse, provider-specific path cache with a task-granular, content-addressed one that any machine on the team can read and write.
What breaks without the fix is not correctness but throughput, and throughput failures compound. Slow CI pushes engineers to batch changes into larger pull requests, which are harder to review and more likely to conflict; it makes main slower to go green, which delays deploys; and it burns runner-minutes that a company pays for by the second. The remote cache sits between the task graph and the actual task execution: Turborepo builds the dependency-ordered graph of tasks, computes each task’s hash, and — before spawning the underlying command — asks the cache whether that exact output already exists. A hit skips the command entirely and unpacks the stored artifact into place; a miss runs the command and uploads the result for the next machine. Everything downstream in this guide is a consequence of getting that hash and that lookup right.
Prerequisites
Remote caching is a Turborepo feature; Turbopack consumes the incremental graph at the framework level, while Turborepo orchestrates and caches whole tasks across the workspace.
- Turborepo 2.x (
turbo@^2). The 1.x line works, but 2.x renamed the top-levelpipelinekey totasksinturbo.json, tightened env-var handling, and stabilized--remote-only/--summarizeoutput. This guide targets 2.x and calls out 1.x differences. - A package manager with workspaces: pnpm 8/9, npm 9+, or Yarn 3+. The lockfile is a hash input, so a clean, committed lockfile is mandatory.
- Node.js 18, 20, or 22. Turborepo’s binary runs across all three; the Node version itself is not in the hash by default, which is a deliberate gotcha covered below.
- A remote cache backend: either a Vercel account (zero-infra, the default) or a self-hosted server implementing the cache HTTP API.
If you have not yet established the per-package build/test scripts and workspace layout, do that first — remote caching amplifies a well-structured task graph and faithfully caches a broken one. For the framework-side compilation cache that pairs with task caching, review Turbopack Incremental Compilation.
The lockfile requirement deserves emphasis because it is the input teams most often get wrong. Turborepo reads resolved dependency versions from the lockfile, not the semver ranges in package.json, precisely so that a ^1.2.0 range that resolves to 1.2.3 on one machine and 1.2.7 on another produces two different hashes — the artifacts genuinely differ, so they should. But this only works if the lockfile is committed and consistent. A repository that gitignores its lockfile, or one where developers run npm install (which may mutate the lockfile) instead of npm ci, will see hashes drift for reasons unrelated to source changes, and every drift is a wasted rebuild. The Node version being absent from the hash is the opposite trap: two machines on Node 18 and Node 20 will happily share a cache entry even though a native addon or a V8-dependent output could differ, so the toolchain has to be pinned into the hash by hand, covered under debugging below.
Core Mechanics: Input Hashing and the Artifact Protocol
What goes into a cache key
For every task Turborepo runs, it computes a single content hash from a deterministic set of inputs. The defaults are: all non-gitignored files in the package directory, the package’s resolved dependency closure (read from the lockfile, not package.json ranges), the task command string, the contents of turbo.json, any globalDependencies, the values of every environment variable the task is declared to depend on, and — critically — the hashes of all upstream tasks pulled in via dependsOn. A ^build entry means “this task depends on the build of every internal package dependency,” so the hash transitively folds in the entire upstream subgraph. Change one byte of a shared ui package and every downstream web#build key changes with it.
The corollary is the source of nearly every confusing miss: anything that affects output but is not in the hash leads to a stale hit, and anything that varies between machines but is in the hash leads to a spurious miss. Environment variables are the usual culprit on both sides. In Turborepo 2.x, env vars referenced in code are not auto-included; you declare them in turbo.json under env (per task) or globalEnv (workspace-wide), and turbo warns about variables it sees used but not declared via its “strict” env mode.
How the hash is computed under the hood
The hash is not a single pass over a pile of files; it is a two-layer computation that mirrors the task graph. Each task hash folds in two categories of input. The first is the global hash, computed once per run from globalDependencies, globalEnv, globalPassThroughEnv membership, the root turbo.json, the lockfile, and the resolved workspace layout. The second is the task-specific hash: the file contents matched by the task’s inputs globs (or all non-gitignored package files when inputs is unset), the task’s command string, the task-local env values, and the already-computed hashes of every task named in dependsOn. Turborepo hashes file contents with a fast non-cryptographic function over the file bytes, sorts the entries so that filesystem iteration order cannot perturb the result, then feeds the sorted set plus the scalar inputs into the final digest that becomes the artifact key like 9f3a….
The dependsOn ordering is what makes the graph correct rather than merely fast. Because web#build folds in the hash of ui#build, and ui#build folds in its own source, a change deep in ui propagates outward: ui#build gets a new hash, which changes web#build’s inputs, which changes its hash, all the way to the root of the graph. Turborepo computes these in topological order, so an upstream hash is always finalized before any downstream task that references it. This is also why a dependsOn: ["^build"] entry with a missing or misconfigured internal dependency edge silently under-invalidates: if the workspace graph does not record that web depends on ui, then ^build resolves to nothing, ui’s hash never enters web#build’s key, and editing ui produces a stale hit on web — the most dangerous class of caching bug, because it ships wrong bytes with a green build.
The hashing cadence is per-run and lazy. Turborepo does not maintain a background daemon-updated hash of the whole repo (though the daemon does watch the filesystem to speed up graph construction); it recomputes the relevant hashes at the start of each turbo run invocation, reading only the files each task declares. This is why narrowing inputs pays off twice: it produces fewer spurious misses and less file I/O during hashing, because Turborepo stats and reads fewer paths.
The cache artifact and its protocol
A cache artifact is a gzip-compressed tarball of the task’s declared outputs (e.g. dist/**, .next/**) plus a small metadata file recording the captured stdout/stderr, so a cache hit can replay the original log output, exit code, and files. The remote cache speaks a small HTTP API the open-source community has standardized:
GET /v8/artifacts/{hash}?teamId=...— returns the tarball (200) or 404 on a miss.PUT /v8/artifacts/{hash}?teamId=...— uploads the tarball after a local task runs.POST /v8/artifacts/events— records hit/miss telemetry (best-effort).- Optional
x-artifact-tagheader carries an HMAC-SHA256 signature when artifact signing is enabled, so a consumer can verify the artifact was produced by a trusted machine before trusting its bytes.
Because the protocol is just authenticated GET/PUT of opaque tarballs keyed by hash, any server that implements it — Vercel’s hosted cache or a self-hosted one — is interchangeable from turbo’s point of view.
The replay is what distinguishes a task cache from a plain artifact store. On a hit, Turborepo does not only unpack dist/** back onto disk; it re-emits the captured stdout and stderr to your terminal and returns the recorded exit code, so a cached test task that printed a passing summary the first time prints the same summary on replay even though nothing ran. This is why declaring outputs: [] on a test task still caches usefully: there are no files to restore, but the logs and the pass/fail exit code are the artifact. It also means a task that failed is, by default, not cached at all — Turborepo caches successful runs, so a red build never poisons the cache with a broken artifact that later replays as a false pass.
The outputs globs are load-bearing in the other direction too. Whatever a task writes that is not matched by outputs is silently dropped from the artifact, so on a cache hit that file will not exist on disk. A common failure is a Next.js build that emits both .next/** and a separate public/build-manifest.json: if only .next/** is declared, the manifest is present after a miss (the command just ran) but absent after a hit (only .next/** was restored), producing a bug that reproduces exclusively on cached runs. The exclusion !.next/cache/** in the example config exists for the opposite reason — that directory is Next’s own incremental cache, is large, and is machine-specific, so packing it into the shared artifact bloats every transfer for no benefit.
Configuration & CLI Reference
The configuration surface splits cleanly into two halves: turbo.json declares what is hashed and cached, while the environment (TURBO_TOKEN, TURBO_TEAM, TURBO_API) declares where the cache lives and how turbo authenticates to it. Keep secrets out of turbo.json and topology out of the environment.
Complete turbo.json
// turbo.json — Turborepo 2.x. Lives at the monorepo root.
{
"$schema": "https://turborepo.com/schema.json",
// Files outside any package whose change should bust every task's hash.
"globalDependencies": ["tsconfig.base.json", ".env"],
// Env vars that affect every task's output (folded into all hashes).
"globalEnv": ["NODE_ENV"],
// Pass-through env vars that should NOT affect hashing (e.g. tokens).
"globalPassThroughEnv": ["TURBO_TOKEN", "TURBO_TEAM", "CI"],
"tasks": {
"build": {
// ^build = build all internal dependencies first; their hashes
// become inputs to this task's hash.
"dependsOn": ["^build"],
// Declared cache inputs; defaults to all package files if omitted.
// Narrowing inputs reduces spurious misses from unrelated edits.
"inputs": ["src/**", "package.json", "tsconfig.json"],
// What gets captured into the cache artifact on success.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
// Env vars that legitimately change build output.
"env": ["NEXT_PUBLIC_API_URL", "SENTRY_RELEASE"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "test/**", "vitest.config.ts"],
// No outputs to cache, but logs/exit code are still replayed on a hit.
"outputs": []
},
"lint": {
"inputs": ["src/**", ".eslintrc.cjs"],
"outputs": []
},
"dev": {
// Long-running task: never cache, never batch.
"cache": false,
"persistent": true
}
}
}
Connecting to Vercel Remote Cache
# Turborepo 2.x. Run from the monorepo root.
# 1. Authenticate the machine against Vercel.
npx turbo login
# 2. Link this repo to a Vercel scope/team's remote cache.
npx turbo link
# 3. From now on, runs read/write the remote cache automatically.
npx turbo run build
CI without an interactive login
# In CI there is no browser for `turbo login`. Use a token instead.
# Set these as CI secrets, not in turbo.json.
export TURBO_TOKEN="<vercel access token>"
export TURBO_TEAM="<your-team-slug>"
# Optional: a custom API for a self-hosted cache (see below).
# export TURBO_API="https://cache.internal.example.com"
# turbo reads TURBO_TOKEN/TURBO_TEAM and uses the remote cache headlessly.
npx turbo run build test lint
Self-hosted cache server
Any server implementing the artifact API works. A minimal env-only setup points turbo at it:
# Turborepo 2.x against a self-hosted cache (e.g. the open-source
# turborepo-remote-cache server backed by S3 or local disk).
export TURBO_API="https://cache.internal.example.com"
export TURBO_TOKEN="<shared bearer token the server validates>"
export TURBO_TEAM="team_acme" # becomes the ?teamId= query param
# Optional: sign artifacts so consumers verify provenance with this key.
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="<32+ byte hmac secret>"
npx turbo run build --remote-only # bypass local cache to prove remote works
To require signature verification, enable it in turbo.json:
// turbo.json — adds artifact signing on top of the config above.
{
"$schema": "https://turborepo.com/schema.json",
"remoteCache": {
// Reject any downloaded artifact whose HMAC tag fails verification
// against TURBO_REMOTE_CACHE_SIGNATURE_KEY.
"signature": true
},
"tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] } }
}
Numbered Workflow: Stand Up a Shared Remote Cache
FULL TURBO, remote caching cannot help.- Write
turbo.jsonwith explicitoutputsandenv. A task with nooutputscaches only logs; a task that reads an undeclared env var produces stale hits. Declare both. Verify the file parses:npx turbo run build --dry=json | jq '.tasks[0].hashOfExternalDependencies'. - Prove local caching works first. Run
npx turbo run buildtwice. The second run must print>>> FULL TURBO(every task a cache hit) in well under a second. If it does not, remote caching will not help — fix the local determinism before going remote. - Connect the remote cache. Run
npx turbo loginthennpx turbo linkfor Vercel, or exportTURBO_API/TURBO_TOKEN/TURBO_TEAMfor a self-hosted server. - Seed the cache from a clean machine. Run
npx turbo run build --remote-onlyso artifacts are forced through the remote backend (local cache disabled), populating the store. - Wire CI to share the cache. Set
TURBO_TOKENandTURBO_TEAMas CI secrets and runnpx turbo run build test lint. The concrete GitHub Actions wiring is detailed in Configuring Remote Cache with Turborepo and Vercel. - Verify cross-machine hits. Clear the local cache (
rm -rf node_modules/.cache/turbo), then runnpx turbo run build --summarize. Open the generated.turbo/runs/*.jsonsummary and confirm tasks show"cache": { "status": "HIT", "source": "REMOTE" }.
Debugging & Failure Modes
Cache misses from undeclared environment variables
Symptom: identical source produces a miss on CI but a hit locally, or vice versa. Root cause: a build-affecting env var (NEXT_PUBLIC_*, a feature flag, an API URL) is not listed under the task’s env, so the two machines hash the same key but produce different output — or it is listed and differs between machines, flipping the key. Diagnose by diffing hash inputs across the two runs: npx turbo run build --dry=json > a.json on each machine and compare the tasks[].environmentVariables arrays. Fix by declaring every output-affecting variable under env/globalEnv and moving non-affecting tokens to globalPassThroughEnv.
Non-deterministic outputs
Symptom: the same inputs produce a cache hit, but the restored artifact misbehaves, or two clean builds of the same commit upload different artifacts. Root cause: the task embeds a timestamp, absolute path, Date.now(), or unsorted directory listing into its output. Turborepo trusts that a given hash maps to one canonical output; non-determinism silently breaks that contract. Diagnose by building twice into separate directories and diff -r-ing them. Fix the source of nondeterminism (pin timestamps, sort globs, strip absolute paths) before relying on the cache.
Inputs too broad — everything misses on every edit
Symptom: editing a README or a test file busts the build cache. Root cause: inputs is unset, so the default includes every package file. Fix by setting a tight inputs glob per task (src/**, package.json, tsconfig.json for build) so unrelated edits do not flip the key.
Remote unreachable or auth failures
Symptom: turbo logs Failed to fetch from remote cache or silently falls back to local-only. Root cause: bad TURBO_TOKEN, wrong TURBO_TEAM slug, or TURBO_API pointing at an unreachable host. Diagnose with npx turbo run build --remote-only -vv to surface the HTTP status. A 403 is a token/team mismatch; a connection error is a network or TURBO_API problem.
Stale hits after a toolchain upgrade
Symptom: a Node or compiler upgrade changes output, but turbo replays the old artifact. Root cause: the Node version is not a hash input by default. Fix by adding the toolchain to the hash explicitly — list the Node version via globalEnv (e.g. a NODE_VERSION you set) or include a .nvmrc/.node-version file in globalDependencies. Confirm the fix by running npx turbo run build --dry=json before and after bumping the version file and diffing globalHashSummary: the global hash must change when the pinned version changes, and stay identical when it does not.
Forks and pull requests writing to the cache
Symptom: hit rate is high on main but pull-request branches almost always miss, or a security review flags that fork builds can write cache entries. Root cause: cache reads and writes both use TURBO_TOKEN, and a token with write scope handed to untrusted fork CI lets an attacker upload a poisoned artifact under a hash a trusted branch will later read. The correct posture is asymmetric: give trusted branches a read-write token and give fork or untrusted pull-request builds a read-only token (or none), so they benefit from existing hits but cannot populate the store. In Turborepo 2.x this is enforced with --cache=remote:r (read-only remote) on untrusted runs versus --cache=remote:rw on trusted ones. Confirm by running a fork build with the read-only flag and checking the summary shows hits served but no PUT uploads in -vv output.
CI integration hinges on the runner starting from an empty local cache, which is the normal state of a fresh container, so the remote cache is doing all the work — there is no local warm cache to mask a misconfiguration. That makes CI the honest test of the setup: if a task that hit locally misses in CI with identical source, the difference is almost always an environment variable present on the laptop and absent (or differently valued) in the runner, or a TURBO_TEAM/TURBO_TOKEN pair that authenticates to a different scope than the one that was seeded. Pin the runner’s Node version to the same value the seed machine used, inject the same declared env values, and the two environments will converge on the same hashes.
Performance Impact & Measurement
The headline metric is the share of tasks served from cache. Capture it with --summarize and read the run summary:
# Turborepo 2.x — emit a machine-readable summary of every task's cache status.
npx turbo run build test lint --summarize
# Then inspect cache sources:
jq '.tasks[] | {task: .taskId, status: .cache.status, source: .cache.source}' \
.turbo/runs/*.json
On a typical monorepo, a fully-warmed remote cache turns a multi-minute CI build+test into a sub-minute restore, because the dominant cost shifts from CPU to network download of a few tens of megabytes of tarballs. The two numbers worth tracking over time are the remote hit rate (target > 80% on PR branches that touch few packages) and artifact transfer time (if downloads dominate, the backend or its region is the bottleneck, not turbo). Use --remote-only to isolate remote-cache behavior from a warm local cache when measuring. A hit rate that collapses after a refactor almost always means a widely-shared package changed and legitimately invalidated its dependents — that is the cache working, not failing.
Compatibility Matrix
pipeline→tasks rename in 2.x.| Component | Versions | Remote cache support | Notes |
|---|---|---|---|
| Turborepo | 2.x | Full; tasks key, --remote-only, --summarize, signing |
Recommended baseline for this guide. |
| Turborepo | 1.x | Full, but uses pipeline key and older env handling |
pipeline → tasks rename is the main migration step. |
| Vercel Remote Cache | hosted | Native (turbo login / turbo link) |
Zero infrastructure; TURBO_TOKEN/TURBO_TEAM in CI. |
| Self-hosted cache | artifact API v8 | Full via TURBO_API/TURBO_TOKEN |
Any server implementing GET/PUT /v8/artifacts/{hash}. |
| Node.js | 18 / 20 / 22 | Runs on all | Node version not auto-hashed — add it manually. |
| pnpm / npm / Yarn | pnpm 8–9, npm 9+, Yarn 3+ | Lockfile is a hash input | Commit the lockfile; uncommitted lockfiles break hashing. |
Related
- esbuild & Turbopack Workflows — the overview covering the incremental and task-level caching layers this guide builds on.
- Configuring Remote Cache with Turborepo and Vercel — the concrete
turbo login/turbo linkand CI-token workflow with verification. - Turbopack Incremental Compilation — the framework-side compilation cache that complements task-level remote caching.
- esbuild & Turbopack Version Compatibility Reference — version pairings that determine which
turbo.jsonschema and cache flags are available.