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.

Local task graph resolving to a remote cache hit or miss across CI machines A package task graph is hashed, then queried against a shared remote cache; one CI machine records a hit and replays output while another records a miss and uploads a fresh artifact. Local task graph ui#build hash 9f3a.. web#build deps: ui#build web#test hash c71e.. Remote cache GET /artifacts/{hash} PUT /artifacts/{hash} CI machine A HIT — replay output 0.4s, no rebuild CI machine B MISS — run + upload new hash, PUT artifact Hash inputs (any change flips the key) source files in the package + declared inputs (globs) resolved dependency versions from the lockfile listed env vars + global deps + the task's own command hashes of upstream tasks (dependsOn ^build) turbo + turbo.json schema version untracked env or OS-specific output → spurious miss
Figure: a hashed package task graph queried against a shared remote cache, producing a hit on one CI machine and a miss-plus-upload on another.

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-level pipeline key to tasks in turbo.json, tightened env-var handling, and stabilized --remote-only/--summarize output. 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.

The four prerequisites for remote caching Remote caching needs Turborepo 2.x with its tasks key, a workspace package manager with a committed lockfile, any of Node 18, 20 or 22, and a remote cache backend that is either Vercel hosted or a self-hosted artifact API server. all four must be in place before a single hit is possible Turborepo 2.xtasks key--remote-only workspacespnpm / npm / Yarncommitted lockfile Node 18/20/22runs on allnot auto-hashed cache backendVercel orself-hosted API
Figure: the lockfile and Node version carry footnotes — one is a hash input, the other is deliberately not.

Core Mechanics: Input Hashing and the Artifact Protocol

What goes into a cache key

Inputs folding into one content hash, then GET or PUT Package files, the resolved lockfile closure, the task command, turbo.json, declared env vars, and the hashes of upstream dependsOn tasks fold into a single content hash; turbo then does a GET on that hash for a hit or a PUT to upload on a miss. package files + inputs lockfile dep closure task command turbo.json + env upstream ^build hashes content hash9f3a… GET → HITreplay artifact PUT → MISSrun + upload
Figure: every listed input feeds one hash — anything affecting output but absent from the list becomes a stale hit.

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-tag header 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.

turbo.json declares topology, the environment declares the backend turbo.json holds globalDependencies, globalEnv, tasks, outputs and inputs — the hashing topology committed to the repo; the environment holds TURBO_TOKEN, TURBO_TEAM and optional TURBO_API — the backend location and secrets kept in CI, never in the file. turbo.json (committed)tasks · inputs · outputsglobalEnv · globalDependencieswhat gets hashed environment (CI secrets)TURBO_TOKEN · TURBO_TEAMTURBO_API (self-hosted)never in turbo.json + what vs where — keep them separate
Figure: topology is committed, credentials are injected — the two configuration halves never overlap.

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

Six steps from turbo.json to verified cross-machine hits Write turbo.json with explicit outputs and env, prove local caching prints FULL TURBO, connect the remote cache, seed it with remote-only, wire CI with token secrets, then verify cross-machine hits report source REMOTE. 1 turbo.jsonoutputs+env 2 local cacheFULL TURBO 3 connectlogin/link 4 seed--remote-only 5 wire CItoken secrets 6 verifysource REMOTE
Figure: step 2 is the gate — if local caching does not print FULL TURBO, remote caching cannot help.
  1. Write turbo.json with explicit outputs and env. A task with no outputs caches 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'.
  2. Prove local caching works first. Run npx turbo run build twice. 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.
  3. Connect the remote cache. Run npx turbo login then npx turbo link for Vercel, or export TURBO_API/TURBO_TOKEN/TURBO_TEAM for a self-hosted server.
  4. Seed the cache from a clean machine. Run npx turbo run build --remote-only so artifacts are forced through the remote backend (local cache disabled), populating the store.
  5. Wire CI to share the cache. Set TURBO_TOKEN and TURBO_TEAM as CI secrets and run npx turbo run build test lint. The concrete GitHub Actions wiring is detailed in Configuring Remote Cache with Turborepo and Vercel.
  6. Verify cross-machine hits. Clear the local cache (rm -rf node_modules/.cache/turbo), then run npx turbo run build --summarize. Open the generated .turbo/runs/*.json summary and confirm tasks show "cache": { "status": "HIT", "source": "REMOTE" }.

Debugging & Failure Modes

Five cache failure modes split by direction Two failure modes cause dangerous stale hits — undeclared env vars and non-deterministic output — while three cause wasteful misses: inputs too broad, an unreachable or unauthenticated remote, and stale hits after a toolchain upgrade because Node is not hashed. dangerous — stale HIT wasteful — spurious MISS undeclared env var → wrong artifact replayed non-deterministic output → broken restore inputs too broad → README busts build remote unreachable / 403 auth toolchain upgrade → Node not hashed
Figure: stale hits (left) are the ones to fear — a miss just wastes time, but a stale hit ships wrong bytes.

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:

Cold build versus a fully-warmed remote cache A cold CI build spends minutes on CPU running every task, while a fully-warmed remote cache collapses to a sub-minute restore dominated by network download of tarballs; the two metrics to track are remote hit rate above 80 percent and artifact transfer time. cost shifts from CPU to network cold CPU: run build + test — minutes warm restore <1 min track: remote hit rate > 80% · artifact transfer time
Figure: a warmed cache turns a multi-minute build into a network-bound restore — hit rate and transfer time are the two dials.
# 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

Backends are interchangeable behind one artifact API From turbo's point of view the Vercel hosted cache and any self-hosted server implementing the v8 artifact API are interchangeable; the main migration cost is Turborepo 1.x to 2.x, which renames the pipeline key to tasks. same GET/PUT /v8/artifacts/{hash} either way turboartifact API v8 Vercel hostedlogin / link self-hostedTURBO_API migration cost: 1.x pipeline → 2.x tasks
Figure: the backend is a swappable detail; the only real version gate is the pipelinetasks 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 pipelinetasks 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.

In-Depth Guides