Configuring Remote Cache with Turborepo and Vercel

This guide walks through enabling Vercel Remote Cache for an existing Turborepo so that builds computed locally or on one CI runner are restored as cache hits everywhere else. For the hashing model and the artifact protocol underneath this setup, read Remote Caching and Distributed Build Coordination first — here we focus narrowly on the turbo login/turbo link handshake, supplying TURBO_TOKEN/TURBO_TEAM in headless CI, and proving with --summarize and --remote-only that hits are genuinely coming from the remote store rather than a warm local cache.

The problem remote caching solves is redundant computation across machines that never see each other’s disks. A local Turborepo cache turns the second turbo run build on one laptop into a >>> FULL TURBO no-op, but that cache lives in node_modules/.cache/turbo and never leaves the machine. The moment a second engineer, a fresh CI runner, or a rebuilt container computes the same task with the same inputs, it recomputes from scratch — a webpack or Next.js build that takes ninety seconds is paid again, and again, by everyone. A ten-person team running CI on every push can burn hours of runner time per day rebuilding artifacts that a colleague already produced byte-for-byte an hour earlier.

Remote caching fixes this by making the cache a network resource keyed on the task hash. When a task’s hash — computed from its source files, its dependencies’ hashes, the resolved task config, and the declared environment variables — matches an artifact already stored under your team’s scope, Turborepo downloads and replays it instead of executing. The critical property is that the hash is deterministic across machines: the same inputs on your laptop and on a CI runner produce the same hash, so either can seed the store and either can restore from it. Where this fits in the pipeline: remote caching sits at the task-graph layer, above the individual bundler. It does not make esbuild or Turbopack faster; it removes the need to invoke them at all when nothing that feeds them has changed. Getting the setup wrong is quiet — you do not get an error, you get a silent 100% miss rate and a CI bill that never drops, which is exactly why the verification steps below matter more than the setup steps.

Local and CI machines sharing a Vercel Remote Cache A developer links the repo with turbo login and turbo link, while CI authenticates with TURBO_TOKEN and TURBO_TEAM, both reading and writing the same Vercel Remote Cache. Developer laptop turbo login turbo link CI runner TURBO_TOKEN TURBO_TEAM Vercel Remote Cache GET/PUT /v8/artifacts scoped by teamId read + write artifacts read + write artifacts Same hash on either machine resolves to the same artifact.
Figure: a developer machine and a CI runner authenticating to the same Vercel Remote Cache, one via interactive login, one via token.

Prerequisites & Reproducible Setup

You need Turborepo 2.x in a workspace-enabled monorepo and a Vercel account (the cache is free on the hobby tier for the cache feature itself). Install and confirm the version:

# Turborepo 2.x. Run from the monorepo root.
pnpm add -Dw turbo@^2          # or: npm i -D turbo@^2 / yarn add -D turbo@^2
npx turbo --version            # expect 2.x.y

# A minimal turbo.json must exist with cacheable tasks and outputs.
cat > turbo.json <<'JSON'
{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    }
  }
}
JSON

Before involving the remote, confirm local caching is healthy — npx turbo run build twice should end with >>> FULL TURBO. Remote caching only shares what local caching already produces correctly.

This precondition is not a formality. The remote cache is a mirror of the local cache: the same hash, the same tarball, uploaded under your team’s scope. If local caching does not produce a stable >>> FULL TURBO on the second run, the underlying cause is almost always non-deterministic hash inputs — a task that writes a timestamp into its output, an unlisted environment variable that changes between runs, or outputs globs that miss a directory the task actually writes. Sharing that instability across machines does not fix it; it multiplies it, because now a hash that flapped locally also flaps between your laptop and CI, and you will chase phantom misses that have nothing to do with the network. Nail down determinism first. The single most common offender is outputs — if a glob omits a folder the task emits, Turborepo caches an incomplete artifact, replays it on the next run, and the missing files silently break downstream tasks. Explicitly negating cache subdirectories ("!.next/cache/**") is equally important: bundling Next.js’s own incremental cache into the artifact bloats every upload and download for no benefit.

Local FULL TURBO is the gate before going remote Run turbo build once to populate the local cache, run it again to confirm it prints FULL TURBO; only when local caching is proven healthy does adding the Vercel remote cache make sense. run 1: populatecache miss, executing run 2: FULL TURBOevery task a hit now add remotelogin / link the remote shares only what the local cache already gets right
Figure: a healthy local cache is the precondition — the remote store only replicates correct local artifacts.

How It Works Under the Hood

When turbo run build starts, it builds the task graph, then for each task computes a hash over four input classes: the hashed contents of the task’s own source files (filtered by the package’s tracked files and any inputs globs), the hashes of the tasks it dependsOn, the resolved task definition from turbo.json, and the values of every environment variable declared in env plus the global sets. That hash becomes the artifact key. Turborepo then issues a GET https://vercel.com/api/v8/artifacts/<hash>?teamId=<id> against Vercel. A 200 means a hit: the response body is the gzipped tarball of the task’s outputs plus a small header carrying the captured stdout log, which Turborepo unpacks into the output directories and replays to your terminal — this is the cache hit, replaying logs line. A 404 means a miss: Turborepo executes the task, tars the declared outputs, and PUTs the tarball back to the same URL so the next machine finds it.

Two details govern correctness. First, the teamId scopes the entire keyspace — two teams computing the same hash never collide, and a machine linked to the wrong team sees a permanent 404 wall. Second, the artifact key is only the hash, so any input that differs between machines — an environment variable present in CI but not locally, a differing lockfile, a platform-specific dependency — produces a different key and therefore a miss, even though the build output would have been identical. The remote cache is exact-match only; it has no notion of “close enough”. This is why the discipline of declaring inputs precisely and passing auth variables through rather than hashing them is the whole game.

Diagnosis Workflow: Linking and Verifying

Four steps to link and verify the remote cache Authenticate the machine with turbo login, link the repository to a team's cache with turbo link, force a remote round-trip with remote-only, then inspect what turbo did with double-verbose or summarize. 1 turbo loginstore token 2 turbo linkpick team 3 --remote-onlyforce round-trip 4 -vv / summarizeread cache.source
Figure: step 3's --remote-only is what proves the artifact actually travelled through Vercel, not a warm local cache.
  1. Authenticate the machine. npx turbo login opens a browser, authenticates against Vercel, and stores a token at ~/.turbo/config.json. The token is a bearer credential scoped to your Vercel account, and it is what every subsequent GET/PUT sends as Authorization: Bearer <token>. In a container without a browser — the normal CI case — there is no way to complete the OAuth redirect, so skip this entirely and use the TURBO_TOKEN env method below. Confirm login worked by checking that ~/.turbo/config.json now contains a token field.
  2. Link the repository to a team’s cache. npx turbo link prompts for the Vercel scope (team) and writes the team id into .turbo/config.json in the repo. From now on every turbo run reads and writes that team’s remote cache. Linking is per-repository, not per-machine: the team id it writes is safe to commit conceptually, but the file also accumulates run logs, so most teams gitignore .turbo/ wholesale and re-link or set TURBO_TEAM explicitly instead. If you link to the wrong team, every request 404s against a keyspace that will never contain your artifacts, and you get a silent all-miss run with no error.
  3. Force a remote round-trip. Run npx turbo run build --remote-only to disable the local cache and prove the artifact moves through Vercel. The first run uploads (cache miss, executing); a second --remote-only run on a freshly-cleared local cache should restore from remote. The reason --remote-only is load-bearing here is that a warm local cache will happily serve every task as a HIT · LOCAL, which looks identical to success in the summary line but tells you nothing about whether the network path works. --remote-only amputates the local tier so the only possible hit is a remote one.
  4. Inspect what turbo actually did. Add -vv to surface HTTP traffic — you will see the artifact URLs, the status codes, and any 403/404 responses that reveal a scoping problem. Add --summarize to write a JSON run report to .turbo/runs/ and read each task’s cache.source; this is the machine-readable ground truth and the thing you assert on in CI. When a hit “should” have happened but did not, -vv almost always shows either a 403 (wrong token or team) or a 404 against a hash you did not expect (a differing input changed the key).

Complete Solution: Config, Token, and CI

For headless CI there is no interactive turbo login. Mint a Vercel access token (Account Settings → Tokens), store it and the team slug as CI secrets, and pass them as environment variables turbo reads automatically. TURBO_TOKEN supplies the bearer credential that turbo login would otherwise have written to ~/.turbo/config.json, and TURBO_TEAM supplies the scope that turbo link would have written to the repo config. With both present in the environment, turbo needs neither the login flow nor the linked file — it authenticates and scopes every request purely from the env, which is exactly what you want on an ephemeral runner that starts with an empty home directory on every job.

Scope the token as tightly as the platform allows. A full-access account token that leaks lets an attacker read and poison your entire team’s cache; a token scoped to a single team limits the blast radius. Rotate it on a schedule and store it as a masked secret, never as a plain repository variable, so it does not appear in build logs. TURBO_TEAM, by contrast, is not a secret — it is just the team slug — so it is fine as a plain CI variable.

Token env vars replace the interactive login in CI In CI, TURBO_TOKEN and TURBO_TEAM injected as secrets replace the interactive turbo login; the runner authenticates headlessly, runs with remote-only and summarize, and a jq assertion reads each task's cache source from the run report. no browser in CI — inject a token instead CI secretsTURBO_TOKENTURBO_TEAM turbo run--remote-only--summarize jq assertion.cache.source== REMOTE
Figure: the token pair is the headless equivalent of turbo login; the jq step turns the run report into a CI assertion.
# .github/workflows/ci.yml — Turborepo 2.x, Node 20, pnpm.
name: CI
on:
  pull_request:
  push:
    branches: [main]

env:
  # turbo reads these to authenticate against Vercel Remote Cache headlessly.
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}   # a Vercel access token
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}        # your team slug, e.g. "team_acme"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # turbo needs git history to scope affected tasks
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      # --remote-only forces hits/misses through Vercel (no local cache in a
      # clean runner anyway), and --summarize writes a report we assert on.
      - run: pnpm exec turbo run build test lint --remote-only --summarize
      # Fail the job if NOTHING was a remote hit on a no-op change (optional).
      - name: Show cache sources
        run: |
          jq '.tasks[] | {task: .taskId, status: .cache.status, source: .cache.source}' \
            .turbo/runs/*.json

A note on the jq assertion above: it is deliberately non-fatal in the starter workflow because a genuinely-changed source file should produce a miss, and you do not want CI to fail simply because someone edited code. The value of printing .cache.source on every run is trend visibility — if a pull request that touches one package shows misses across every package, a hash input is leaking a per-run value and you have found it before it silently doubles your CI minutes. Once you trust the setup, you can promote the assertion to a hard gate on a synthetic no-op job (below) where every task is expected to be a remote hit.

To require that downloaded artifacts were produced by a trusted machine, enable signature verification. Vercel Remote Cache supports the x-artifact-tag HMAC header; turn it on in turbo.json and provide the shared key:

// turbo.json — add artifact signing. The key lives in env, never in the file.
{
  "$schema": "https://turborepo.com/schema.json",
  "remoteCache": { "signature": true },
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
  }
}
# Provide the HMAC key in every environment that reads or writes the cache.
# A downloaded artifact whose signature fails verification is rejected and
# the task is re-run locally instead of trusting tampered bytes.
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="<32+ byte shared secret>"

Signing changes the trust model in a specific way. Without it, any machine holding a valid write token can upload an artifact under any hash, and every reader trusts those bytes implicitly — a compromised token becomes a supply-chain vector, because a poisoned build artifact is replayed as if it were legitimate output. With signing on, each uploaded artifact carries an HMAC computed over the tarball and the hash using the shared key; readers recompute the tag and reject any artifact whose signature does not match, falling back to local execution. The trade-off is key distribution: the signature key must be present and identical in every environment that reads or writes, so a key mismatch between two runners means one uploads artifacts the other rejects, and you get correct-but-slow behavior (constant re-runs) rather than an error. Treat the key like the token — a masked secret, rotated deliberately, never in turbo.json.

When Not to Use This

Remote caching is not free of overhead, and there are shapes of repository where it loses. If your tasks are individually fast — a lint that runs in under a second, a type-check on a tiny package — the cost of a network round-trip to fetch the artifact can exceed the cost of just running the task, especially on a runner with a slow link to Vercel. Turborepo will still short-circuit execution, but wall-clock time barely moves. Remote caching pays off in proportion to how expensive the cached task is relative to the download; a two-minute Next.js build restored as a two-second download is a clear win, a 300ms task is a wash. Similarly, if almost every CI run legitimately changes almost every package — a repository with a single giant package, or a shared file that every task depends on — the hit rate is structurally low and no amount of cache tuning fixes it; the fix there is to break the dependency graph so unrelated packages stop invalidating each other. Finally, for a solo developer who never runs CI and only builds on one machine, the local cache already captures everything; the remote adds a token to manage and a network dependency for no shared benefit.

Verification

Confirm remote hits with two commands. First, clear the local cache and run with --remote-only and --summarize:

Reading cache source to tell REMOTE from LOCAL and MISS After clearing the local cache and running remote-only, source REMOTE with status HIT confirms the artifact came from Vercel; source LOCAL means the local cache was not cleared; status MISS means the artifact was never uploaded or a hash input differs. what the cache.source field is telling you HIT · REMOTErestored from Vercelthe goal HIT · LOCALlocal cache not clearedrm -rf .cache/turbo MISSnever uploaded, ora hash input differs
Figure: only HIT · REMOTE proves the shared cache works — the other two point at a specific misconfiguration.
# Turborepo 2.x. Prove a hit came from Vercel, not a warm local cache.
rm -rf node_modules/.cache/turbo
TURBO_TOKEN="$TURBO_TOKEN" TURBO_TEAM="$TURBO_TEAM" \
  npx turbo run build --remote-only --summarize

# Read the cache source for every task from the generated run report.
jq '.tasks[] | {task: .taskId, status: .cache.status, source: .cache.source}' \
  .turbo/runs/*.json

A successful remote restore shows "status": "HIT" with "source": "REMOTE" for each task, and the run finishes in seconds. The inline log also prints cache hit, replaying logs per task and a final >>> FULL TURBO. If you see "source": "LOCAL", your local cache was not actually cleared; if you see MISS, either the artifact was never uploaded or a hash input differs from the machine that seeded it.

To turn this into a standing regression check rather than a one-off, add a job that runs on the same commit CI already built and asserts that every task is a remote hit. Because nothing changed, any miss is a real defect — a leaking hash input, a token that stopped working, or an outputs glob that captured a per-run value. This is the assertion promoted to a hard gate:

#!/usr/bin/env bash
# Turborepo 2.x. Fail if any task is NOT a remote hit on an unchanged tree.
# Run this AFTER a build job has already seeded the cache for this commit.
set -euo pipefail
rm -rf node_modules/.cache/turbo
pnpm exec turbo run build test lint --remote-only --summarize

report="$(ls -t .turbo/runs/*.json | head -1)"
# Count tasks whose cache was not a REMOTE hit.
bad="$(jq '[.tasks[] | select(.cache.status != "HIT" or .cache.source != "REMOTE")] | length' "$report")"
if [ "$bad" -ne 0 ]; then
  echo "cache regression: $bad task(s) were not a remote hit" >&2
  jq -r '.tasks[] | select(.cache.status != "HIT" or .cache.source != "REMOTE") | "  \(.taskId): \(.cache.status)/\(.cache.source)"' "$report" >&2
  exit 1
fi
echo "all tasks restored from remote — cache is healthy"

The second half of verification is server-side: confirm the artifact actually landed under your team’s scope, not just that your machine thinks it uploaded. -vv prints the PUT and its status code; a 200/202 on the upload and a matching 200 on a later GET from a different machine closes the loop. If uploads report success but a second machine still misses, the two machines are computing different hashes or resolving different team ids — compare the hash printed under -vv on each.

Gotchas & Edge Cases

Four configuration traps in the Vercel cache setup TURBO_TEAM must be the slug not the display name, tokens must never be committed to the repo, fetch-depth zero is required for affected-package detection, and auth tokens belong in globalPassThroughEnv rather than a task's hashed env. TURBO_TEAM = slugteam_acme, not the display name never commit tokensCI secrets + ~/.turbo only fetch-depth: 0affected-task detection needs history globalPassThroughEnvtoken ≠ hash input
Figure: the last trap — a token landing in a task's env instead of pass-through — is the classic "works locally, misses in CI."
  • TURBO_TEAM must be the slug, not the display name. The symptom is a run where every task misses and the summary never mentions the remote at all. The root cause is that turbo sends the display name as the teamId, Vercel has no keyspace under that value, and every request returns 403/404. The fix is to use the value from the team’s URL (team_acme), or the team id written into .turbo/config.json after a successful turbo link. Confirm it by running with -vv and checking that artifact requests carry the slug you expect and return 200, not 403.
  • Don’t commit .turbo/config.json secrets. The symptom here is not a broken build but a leaked credential — a token committed to history is compromised the moment the repo is cloned. turbo link writes only a team id (safe), but the login token belongs in CI secrets and ~/.turbo/config.json, never in the repo. The fix is to add .turbo/ to .gitignore so run logs and any local config never get staged, and to rotate any token that has ever touched a commit. Confirm with git log -p -- .turbo that no token string was ever committed.
  • fetch-depth: 0 matters. The symptom is a hit rate that swings run to run for no apparent reason, or tasks that run when they should have been skipped. The root cause is a shallow actions/checkout (the default fetch-depth: 1): Turborepo uses git history to scope --filter and affected-package detection, and without the full history it cannot compute the diff base correctly, so it over- or under-runs tasks and muddies your hit-rate measurements. The fix is fetch-depth: 0. Confirm by comparing the set of executed tasks between a shallow and a full checkout on the same commit.
  • Pass-through vs hashed env vars. This is the classic “works locally, misses in CI.” The symptom is 100% local hits and 100% CI misses on identical source. The root cause is an auth or environment variable — TURBO_TOKEN, CI, a GITHUB_* value — sitting in a task’s hashed env, so its different value in CI changes the task hash and guarantees a miss against artifacts your laptop seeded. The fix is to keep authentication and environment-detection variables in globalPassThroughEnv (they authenticate and pass through without entering the hash) and reserve env strictly for variables that genuinely change the build output. Confirm by diffing the printed task hash for one task between local and CI under -vv; if the hashes differ on unchanged source, an env var is leaking into the key.

Performance Considerations

The dominant cost once the cache is working is transfer, not compute. Artifact size is set by your outputs globs, so keep them tight: a dist/** that accidentally includes a source map or a copied node_modules folder turns a small artifact into a multi-megabyte upload and download on every run. Prefer negating heavy subdirectories ("!.next/cache/**") over uploading them. On the read side, Turborepo fetches artifacts in parallel across the task graph, so a wide graph of small artifacts restores faster than a narrow graph of huge ones — another reason to split monolithic packages. Concurrency is bounded by --concurrency; the default is usually right, but a runner with a fast link and many small tasks can benefit from raising it. Finally, remember the cache only removes execution time — install time (pnpm install) is not cached by Turborepo, so pair the remote cache with the package manager’s own store cache (cache: pnpm in setup-node) or the two together still leave the install cost on the table.