Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Remote Caching Setup

A remote cache turns a build artifact produced once — on any machine, by any contributor — into a hit for everyone else who runs the same task with the same inputs. Without it, every CI job and every fresh git clone re-runs work that has already been computed elsewhere, and your build minutes scale with the number of people on the team rather than the number of meaningful changes. This page covers how to stand up a remote cache for a JavaScript monorepo, how the cache key is derived, how to secure it against poisoning, and how to wire it into CI so that the first job to build a package warms the cache for every job that follows.

Remote caching is one of the highest-leverage pieces of a Monorepo Architecture & Orchestration setup, and it only works if the task definitions feeding it are deterministic. The cache is downstream of your pipeline: it stores whatever your task runner tells it to store, keyed by whatever inputs your task runner decides are relevant. Get the Turborepo Pipeline Configuration wrong — a missing outputs glob, a volatile file leaking into inputs — and the remote cache faithfully stores garbage or never gets a hit. Decide which runner you are committing to first by reading Choosing a Monorepo Task Runner; the cache topology differs between Turborepo and Nx.

Remote cache topology Developer and CI runners share one remote cache: a local hit avoids the network, a local miss falls back to the remote store, and writes only flow from protected branches. Dev workstation local .turbo cache read + write CI runners ephemeral, no local cache Remote cache keyed by task hash signed artifacts Object store S3 / blob TTL eviction write read
One cache, two readers: developers and ephemeral CI runners share artifacts keyed by a deterministic task hash, with writes scoped to protected branches.

The problem statement

Every machine that builds a package computes the same dist/ from the same source. A remote cache makes that computation happen once. The hard parts are not turning the feature on — they are guaranteeing that two machines derive the same cache key for the same logical work, and stopping an untrusted machine from writing a poisoned artifact under a key that a trusted machine will later read. Everything below serves those two goals.

The problem statement Every machine that builds a package computes the same dist/ from the same source. The problem statement Every machine that builds a package computes the same dist/ from the same source.
The problem statement — the core idea of this section at a glance.

Architecture and prerequisites

Decide the cache topology and network boundaries before you integrate caching with any task runner. A remote cache is an HTTP service in front of an object store; the runner uploads a tar of a task's outputs under a key, and downloads it later when the key matches.

Remote cache flow Local miss uploads an artifact; peers download on a hash hit. compute hash inputs + env miss → build + upload store artifact peer hit → download skip rebuild
A shared artifact store lets one machine's build serve all the others.

Pre-deployment checklist:

  • Terminate TLS 1.2+ at the load balancer or edge proxy; the cache protocol carries tokens.
  • Allow-list the IP ranges of your CI runners and, optionally, developer egress.
  • Rotate access tokens automatically (90-day maximum) through your secrets manager.
  • Confirm round-trip latency from CI to the cache endpoint is low; a slow cache that you still wait on is worse than no cache.
# Verify the TLS handshake and certificate validity window
openssl s_client -connect cache.example.com:443 -servername cache.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates

# Measure connect and first-byte latency to the cache health endpoint (target < 50ms)
curl -o /dev/null -s -w "connect: %{time_connect}s  ttfb: %{time_starttransfer}s\n" \
  https://cache.example.com/health

If those numbers are healthy, the cache is reachable and trusted; the remaining work is configuring the runner to talk to it.

Tool-specific configuration

The runner decides what to cache, where the outputs live, and which inputs feed the hash. Declare these explicitly. Implicit caching of non-deterministic artifacts is the single most common cause of "cache miss in CI but hit locally" reports.

Tool-specific configuration The runner decides what to cache, where the outputs live, and which inputs feed the hash. Tool-specific configuration The runner decides what to cache, where the outputs live, and which inputs feed the hash.
Tool-specific configuration — the core idea of this section at a glance.

Turborepo (v2.0+)

Keep these task definitions in lockstep with your Turborepo Pipeline Configuration so the same outputs and inputs govern both local and remote behavior.

{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "enabled": true,
    "signature": true,
    "timeout": 30
  },
  "tasks": {
    "build": {
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "test": {
      "outputs": ["coverage/**"],
      "inputs": ["src/**", "tests/**"]
    }
  }
}

Critical fields: signature: true enables HMAC verification of every artifact, so a reader rejects anything not signed with the shared key. timeout (seconds, in v2) caps how long the runner waits on a degraded endpoint before falling back to local execution. Explicit outputs globs prevent partial restoration — and the !.next/cache/** exclusion keeps a volatile framework cache out of the artifact. Turborepo v2 renamed pipeline to tasks; on v1 the same block lives under pipeline.

Nx (v17+)

Configure the workspace runner to match your Nx Workspace Architecture.

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx-cloud",
      "options": {
        "cacheableOperations": ["build", "test", "lint"],
        "accessToken": "${NX_CLOUD_ACCESS_TOKEN}"
      }
    }
  }
}

Critical fields: cacheableOperations must exclude e2e and any network-dependent task. accessToken resolves at runtime from the environment — never commit a plaintext token.

Connecting a task runner to a remote cache is a matter of a token and a team identifier, after which every hit-eligible task is shared across runners and machines:

# Turborepo: authenticate CI to the shared cache
export TURBO_TOKEN="$SHARED_CACHE_TOKEN"
export TURBO_TEAM="acme"
pnpm turbo run build test --filter='...[origin/main]'
// turbo.json — outputs must be declared for a task to be cacheable
{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
  }
}

With the credential set and outputs declared, a task computed on one runner — or a developer's machine — is downloaded rather than recomputed elsewhere. Scoping the write credential to trusted branches keeps a pull request from poisoning the shared cache, so the cache accelerates the team without becoming an injection point.

Cache internals: how the key is derived

A remote cache is keyed, not by your branch or your timestamp, but by a hash of everything that could change a task's output. For a single package's build task the hash folds together the hashed contents of every file matched by inputs, the resolved values of every variable in env, the hashes of upstream dependencies declared via dependsOn (the ^build chain), the package manager lockfile, and the runner's own version. Two machines that agree on all of those derive the same key and therefore share the same artifact.

Hash key inputs Everything that feeds the cache key. source files hashed contents dependency graph resolved versions task config dependsOn + outputs env passthrough declared vars only
The key mixes every input that could change the output.

This is why determinism matters more than the network. If a .env.local, an absolute path, or a build timestamp leaks into the hashed set, two machines compute different keys and never share. Chasing those mismatches is the subject of Fixing Turborepo Remote Cache Misses, which walks through diffing local and CI hashes to find the contaminating input.

The cache key is the heart of remote caching, and its correctness depends on capturing every input that could change a task's output. The key is a hash of the task's source files, its dependencies' relevant files, the resolved dependency graph (via the lockfile), the task configuration, and any declared environment variables. A key that omits an input either misses when it should hit — wasting the cache — or, worse, replays a result that no longer matches the inputs, which is a correctness bug that ships stale artifacts. Making the key complete is therefore the first requirement of a trustworthy cache.

Portability across environments is the second requirement, and it follows from completeness. A cache computed on a developer's laptop is reused in CI only if both hash the same inputs to the same key, so anything that differs between environments — an undeclared env var, an unpinned Node version, an absolute path leaking into an input — produces divergent keys and a permanent miss. When a shared cache mysteriously never hits across CI and local, the cause is almost always one such divergent input, and diffing the dry-run hash inputs between the two environments names it immediately.

SaaS versus self-hosted topology

Before wiring credentials you must decide where the artifacts physically live, because that choice drives your security model, your latency, and your bill.

Cache topologies SaaS versus self-hosted remote cache trade-offs. Axis SaaS Self-hosted Setup token + login deploy a server Control vendor managed your storage + auth Best for small teams strict data control
Buy convenience or own the storage and network path.
Dimension Managed SaaS Self-hosted
Setup effort Minimal — a token and a team slug You run an HTTP service and an object store
Data residency Artifacts leave your network Stays inside your perimeter
Latency Depends on provider region You place it next to your runners
Cost model Per-seat or per-bandwidth Storage + egress you control
Best for Small teams, public code Regulated data, large bandwidth, air-gapped CI

The deciding question is usually data residency: if build outputs may contain anything you are contractually barred from sending to a third party, you self-host. Latency is the secondary factor — a cache one region away can be slower to fetch than rebuilding a small package, which silently erodes the benefit. Teams that land on the self-hosted side should follow Self-Hosting a Turborepo Remote Cache for the server and storage layout; everyone else can point TURBO_TOKEN/TURBO_TEAM at the managed endpoint and move on.

Execution strategy

In CI, the first job to build a given package uploads the artifact; every subsequent job — in the same workflow or a later one — restores it. The flags below make that explicit and bounded.

Execution strategy In CI, the first job to build a given package uploads the artifact; every subsequent job — in the same workflow or a lat Execution strategy In CI, the first job to build a given package uploads the artifact; every subsequent job — in the same workflow or a later one — restores it.
Execution strategy — the core idea of this section at a glance.
# Build the affected graph, continuing past failures, with a bounded worker pool
pnpm exec turbo run build \
  --filter='...[origin/main]' \
  --continue \
  --concurrency=4

# Inspect what was hit vs. missed without running anything
turbo run build --dry=json | jq '.tasks[] | {id: .taskId, cache: .cache.status}'

Set --concurrency to the runner's vCPU count; over-provisioning turns parallel uploads into an I/O bottleneck. --continue lets independent tasks finish even when one fails, so a single broken package does not starve the rest of the cache.

Security and isolation

A shared cache is a shared trust boundary. The threat is poisoning: an attacker (or a buggy job) writes a malicious artifact under a key that a trusted build will later read as a hit.

Security and isolation A shared cache is a shared trust boundary. Security and isolation A shared cache is a shared trust boundary.
Security and isolation — the core idea of this section at a glance.
  • Artifact signing. Set signature: true (Turborepo) and provision a TURBO_REMOTE_CACHE_SIGNATURE_KEY so readers reject any artifact not signed with the shared secret.
  • Branch-scoped writes. Grant write only to main and release/*. Pull-request and fork builds run read-only — they may benefit from the cache but can never populate it.
  • Token hygiene. Issue short-lived tokens via OIDC federation rather than long-lived secrets stored in repository settings, and rotate the signature key on a schedule.

CI/CD integration

The workflow below injects cache credentials as masked secrets, scopes write access by branch, and degrades gracefully when the cache is unreachable.

CI/CD integration The workflow below injects cache credentials as masked secrets, scopes write access by branch, and degrades gracefully w CI/CD integration The workflow below injects cache credentials as masked secrets, scopes write access by branch, and degrades gracefully when the cache is unreachable.
CI/CD integration — the core idea of this section at a glance.
# .github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
      TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.CACHE_SIGNATURE_KEY }}
      # Pull requests read the cache but must not write to it
      TURBO_REMOTE_ONLY: ${{ github.event_name == 'pull_request' }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # needed for --filter '...[origin/main]' to diff
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - name: Build with remote cache
        run: pnpm exec turbo run build --continue --concurrency=4
        timeout-minutes: 15

The fetch-depth: 2 line is load-bearing: change-based filtering needs at least the previous commit to compute a diff. The timeout-minutes guard prevents a hung cache connection from holding a runner indefinitely. For the deeper tuning that turns a working cache into a fast one — compression, parallel uploads, warming strategies — see Optimizing Turborepo Remote Cache for CI. Teams that cannot send artifacts to a SaaS endpoint should follow Self-Hosting a Turborepo Remote Cache to run the protocol behind their own object store.

Validation and performance tuning

Verify hit rates, confirm signature enforcement, and put an eviction policy in place so the store does not grow without bound.

Validation and performance tuning Verify hit rates, confirm signature enforcement, and put an eviction policy in place so the store does not grow without Validation and performance tuning Verify hit rates, confirm signature enforcement, and put an eviction policy in place so the store does not grow without bound.
Validation and performance tuning — the core idea of this section at a glance.
# Hit/miss breakdown for a build, machine-readable
turbo run build --dry=json | jq '.tasks[] | {id: .taskId, cache: .cache.status}'

# Reset the Nx local cache when debugging a suspected stale hit
npx nx reset

# Verify an artifact's signature against the public key
openssl dgst -sha256 -verify cache-signature.pub -signature artifact.sig artifact.tar.zst

Tuning parameters:

  • Concurrency. Pin --concurrency to the runner vCPU count.
  • Compression. Turborepo v2+ streams zstd-compressed artifacts natively; enable transport compression at the proxy if you self-host.
  • Eviction. Use tiered retention: main 90 days, feature branches 14 days, failed or unverified runs 0 days.

Validating the cache means measuring the hit rate rather than assuming it, because a cache that is configured but never hits delivers nothing. The dry-run and summary outputs show each task's hash and whether it hit, which lets you diagnose a low rate: a task that always misses has an unstable input — an undeclared env var, an embedded timestamp — while a task that hits locally but misses in CI has an input that differs between environments. Tracking the hit rate over time catches a regression when a change destabilizes a key, and the fix is almost always to make the task deterministic: declare its env inputs, remove non-deterministic output, and write only to declared outputs.

Reasoning about cache hits in CI

Once the cache is live, most operational questions reduce to "why did (or didn't) this task hit." The answer is always in the hash, and you can read it directly rather than guessing. A dry run prints the resolved hash and the cache status for every task in the graph; the summary file (written by --summarize) records the inputs that fed each hash. Together they let you trace any miss back to a concrete cause without rebuilding.

Reasoning about cache hits in CI Once the cache is live, most operational questions reduce to "why did (or didn't) this task hit." The answer is always i Reasoning about cache hits in CI Once the cache is live, most operational questions reduce to "why did (or didn't) this task hit." The answer is always in the hash, and you can read it directly
Reasoning about cache hits in CI — the core idea of this section at a glance.
# Per-task hash and hit/miss for the whole graph
turbo run build --dry=json \
  | jq '.tasks[] | {task: .taskId, hash: .hash, status: .cache.status}'

Three patterns cover almost every report. A task that hits locally but misses in CI is almost always a hashed input that varies between the two environments — a machine-specific path, an unpinned Node.js version, a stray .env.local. A task that misses on every run regardless of environment usually has no outputs declared, so there is nothing to store and restore. And a whole graph that invalidates after an unrelated edit points at something over-broad in globalDependencies or a too-greedy inputs glob. Each of these has a fix in the configuration above; the diagnostic step is simply to read the hash rather than assume.

When the mismatch is subtle, the full walkthrough of diffing local and CI hashes lives in Fixing Turborepo Remote Cache Misses, and the throughput-oriented tuning that follows a clean hit rate is in Optimizing Turborepo Remote Cache for CI.

Reasoning about why a CI build hit or missed the cache is the core skill of running remote caching well, and it comes down to the hash. A hit means every input that fed the key was identical to a previous run whose result was stored; a miss means at least one input differed. When a miss is unexpected — the code did not change but the cache did not hit — the divergent input is almost always environmental: an undeclared env var whose value differs, an unpinned tool version, or an absolute path that leaked into an input. The dry-run hash output makes the inputs visible, so diffing them between a hitting and a missing run names the cause.

The inverse question — why a build hit when you expected a miss — is rarer but more dangerous, because it means the hash failed to capture something that changed the output. This is the staleness bug: a task read an undeclared env var or wrote outside its declared outputs, so a changed input did not change the key and a stale result replayed. Preventing it is why declaring every input precisely matters; a cache that hits when it should miss silently ships wrong artifacts, which is worse than one that misses when it should hit and merely wastes time.

Common pitfalls and mitigation

Mistake Impact Resolution
Hardcoding cache tokens in turbo.json Credential leak; supply-chain exposure Inject via masked CI secrets or OIDC federation
Missing explicit outputs globs Partial or corrupt artifact restoration Declare exact output globs per task
Unrestricted write on pull requests Cache poisoning from untrusted code Run forks and PRs in TURBO_REMOTE_ONLY read mode
No artifact signing Readers trust unverified artifacts Set signature: true and provision a signature key
Volatile files in inputs Hash drift; perpetual misses Exclude .env.local, logs, OS binaries from inputs
Ignoring timeout on a degraded endpoint Silent CI hangs Cap timeout and let the build fall back to local execution
Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows. Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows.
Common pitfalls and mitigation — the core idea of this section at a glance.

SaaS versus self-hosted, and securing the shared cache

The remote cache can be a managed service or self-hosted, and the choice trades convenience against control. A SaaS cache (Vercel's for Turborepo, Nx Cloud) needs only a token and a team identifier and handles storage, availability, and access; a self-hosted cache — an S3-compatible bucket, a custom server — gives you full control over where artifacts live and who can reach them, at the cost of running and securing the infrastructure. Small teams usually take the SaaS path for zero operations; teams with strict data-residency or network requirements self-host.

SaaS versus self-hosted, and securing the shared cache The remote cache can be a managed service or self-hosted, and the choice trades convenience against control. SaaS versus self-hosted, and securing the shared cache The remote cache can be a managed service or self-hosted, and the choice trades convenience against control.
SaaS versus self-hosted, and securing the shared cache — the core idea of this section at a glance.

Either way, the shared cache is a trust boundary that demands the same threat modeling as any shared, mutable, replayed resource. Because a cache hit replays a stored artifact verbatim, an attacker who can write a poisoned entry injects a compromised build into everyone who later replays it — a supply-chain attack that bypasses code review. The mitigations are to scope cache write access to trusted branches, keep pull-request builds read-only against the shared cache, include the lockfile in the key so a dependency change cannot replay an artifact built against the old graph, and run installs with ignored scripts so a compromised dependency cannot tamper during the build that populates the cache. Configured this way the cache is a reproducible accelerator; configured carelessly it is a new attack surface.

Validating and tuning cache performance

A remote cache that is configured but not validated can quietly under-deliver, so measuring the hit rate is what turns caching from a hopeful setting into a reliable accelerator. The dry-run and summary outputs show each task's hash and whether it hit or missed, which lets you diagnose a low hit rate: a task that always misses usually has an unstable input — an undeclared env var, a timestamp embedded in output, a non-deterministic build — while a cache that hits locally but misses in CI has an input that differs between environments. Tracking the hit rate over time catches a regression when a change destabilizes a key, on the pull request that caused it.

Cache tuning Measure hit rate, stabilize keys, prune the store. measure hit rate dry-run + summary stabilize keys declare inputs prune store keep restores fast
A validated hit rate turns caching from a hopeful setting into a reliable accelerator.

Tuning follows from the diagnosis. Making a task deterministic — declaring its env inputs, removing embedded timestamps, writing only to declared outputs — restores its cacheability; narrowing overly-broad inputs stops unrelated changes from invalidating a key; and keeping the store pruned prevents the cache from growing until restores slow down. The goal is a cache whose key is complete enough to be correct and stable enough to hit, validated by a hit-rate metric rather than assumed. A well-tuned remote cache is often the single largest speed-up available to a mature monorepo, because it turns work done once — on any machine, by anyone — into work reused everywhere.

Frequently Asked Questions

How do I prevent cache poisoning in a shared monorepo? Enable artifact signing so readers reject any artifact not signed with the shared key, grant write access only to protected branches, and run pull-request and fork builds in read-only mode so untrusted code can never populate the cache.

Why is the cache key the same hash on every machine, and what breaks it? The key is a hash of the task's matched inputs, declared environment variables, upstream dependency hashes, the lockfile, and the runner version. It breaks when a machine-specific or time-varying file leaks into the hashed set, which is why two machines disagree and never share an artifact.

Can I run a remote cache without a SaaS provider? Yes. The remote cache protocol is an HTTP contract you can serve yourself in front of S3 or any blob store, with TLS, IP allow-listing, and token rotation. See Self-Hosting a Turborepo Remote Cache for a working setup.

How do I handle cache misses on the very first CI run? A cold cache simply executes the task and uploads the result, so the first run is no slower than a no-cache build. To avoid every contributor paying that cost, run a scheduled job on main that warms the cache for the critical dependency graph.

Why does my remote cache hit locally but miss in CI?

A hashed input differs between the two environments — commonly an undeclared env var, an unpinned Node or tool version, or an absolute path leaking into an input. Declare every input the task reads, pin toolchain versions, and diff the dry-run hash inputs between environments to find the divergence.

Is a shared remote cache a security risk?

It can be, because a cache hit replays a stored artifact verbatim — a poisoned entry injects a bad build into everyone who replays it. Scope write access to trusted branches, keep PR builds read-only, include the lockfile in the key, and install with --ignore-scripts when populating the cache.

Is a remote cache worth setting up?

For a monorepo with more than a couple of engineers or a CI pipeline that rebuilds often, almost always — it turns work computed once, by anyone, into work reused everywhere. The main requirements are precise cache-key inputs and scoping write access to trusted branches so the shared cache stays fast and safe.

How do I know my remote cache is actually working?

Measure the hit rate with the runner's summary output. A task that always misses has an unstable input; one that hits locally but misses in CI has an input that differs between environments. Tracking the rate over time catches a key-destabilizing change on the PR that caused it.

Related

Monorepo Architecture & Orchestration