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.
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.
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.
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.
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.
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.
| 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.
# 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.
- Artifact signing. Set
signature: true(Turborepo) and provision aTURBO_REMOTE_CACHE_SIGNATURE_KEYso readers reject any artifact not signed with the shared secret. - Branch-scoped writes. Grant
writeonly tomainandrelease/*. 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.
# .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.
# 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
--concurrencyto 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:
main90 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.
# 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 |
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.
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.
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
- Optimizing Turborepo Remote Cache for CI — compression, parallel uploads, and warming strategies that turn a working cache into a fast one.
- Fixing Turborepo Remote Cache Misses — diagnose hash drift when a build hits locally but misses in CI.
- Self-Hosting a Turborepo Remote Cache — run the cache protocol behind your own object store with no SaaS dependency.
- Turborepo Pipeline Configuration — the
inputs/outputsdefinitions that determine what the cache stores and how its keys are derived. - Choosing a Monorepo Task Runner — pick the runner whose cache model fits your workspace before you build the topology.