Optimizing Turborepo Remote Cache for CI
Your remote cache works — artifacts upload, hits register — but CI is still slow, uploads stall under concurrency, and the hit rate sits below where it should be. This page is about squeezing throughput out of a working remote cache: standardizing the task hash across runners, warming the local cache, tuning concurrency and timeouts, and keeping artifact payloads small. If your cache is missing outright or returning 401, fix correctness first with Fixing Turborepo Remote Cache Misses; the steps below assume the connection is healthy and you want it faster.
Symptoms
You are in the right place if CI logs show any of the following despite a configured cache:
• turbo run build: 12 cache hit, 38 cache miss (expected near-total hits on an unchanged PR)
• Error: failed to upload artifact: context deadline exceeded
• WARNING failed to contact remote cache: i/o timeout (falling back to local)
• total upload time 4m12s on a build that compiles in 40s
The pattern is high miss rates on unchanged code, upload timeouts under load, or upload time dwarfing compile time. None of these are auth failures; they are tuning and determinism problems.
Root cause analysis
Three forces drag a working cache down. First, hash drift between local and CI runners: a difference in OS, Node.js version, or a volatile file in inputs makes the same logical build produce a different key, so the artifact a teammate uploaded never matches. The hash is derived exactly as described in Remote Caching Setup — matched inputs, declared env, upstream dependency hashes, lockfile, runner version — so any unpinned dimension is a miss. Second, network saturation: uploading every fresh artifact serially, uncompressed, past a tight default timeout stalls the pipeline. Third, payload bloat: caching volatile directories (.next/cache, node_modules) balloons the artifact, slowing both upload and download. A resilient cache is foundational to any Monorepo Architecture & Orchestration setup, so these three are worth hunting down precisely.
A remote cache underdelivers in CI when its hit rate is low, and the hit rate is low when cache keys are unstable or diverge between the runs that could share results. Every input that feeds a task's hash — source, resolved dependencies, task config, declared environment — must be identical for two runs to share a cached result, so an unstable input (an embedded timestamp, an undeclared env var) or a divergent one (a different tool version between runners) produces a miss where a hit was expected. Optimizing the cache is largely a matter of making keys stable and inputs precisely declared.
The other common cause of poor CI cache performance is not populating the cache from the right place. If only pull-request builds run, but they are read-only against the shared cache for security, nothing writes the cache and every build misses. Trusted branch builds must write the cache so that the results they compute are available for later runs — including pull requests that legitimately read it — which is what turns the remote cache from an empty store into a populated accelerator.
Resolution and config patch
Work through these in order; each step targets one of the three forces above.
1. Diff local vs. CI hashes to find drift
# On your workstation
turbo run build --dry=json > local_hashes.json
# On the CI runner, then compare
turbo run build --dry=json > ci_hashes.json
diff <(jq -S '.tasks[] | {id: .taskId, hash: .hash}' local_hashes.json) \
<(jq -S '.tasks[] | {id: .taskId, hash: .hash}' ci_hashes.json)
Any differing hash points at an input that is not stable across machines. Remove it from inputs or pin it (Node.js version, lockfile, env list).
2. Warm the local cache before the build
Restoring .turbo between runs lets a runner reuse its own work even before consulting the remote store:
# .github/workflows/ci.yml
- name: Restore Turborepo local cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
3. Tune concurrency, timeout, and scope
turbo run build \
--remote-cache-timeout=300 \
--concurrency=10 \
--filter='...[origin/main]'
--remote-cache-timeout (seconds) prevents a slow upload from aborting on the default deadline; --concurrency bounds parallel workers so uploads do not saturate the link; --filter restricts the run to the affected graph so you never upload artifacts for untouched packages.
4. Keep payloads small
Exclude volatile directories from outputs so the cached tar carries only deterministic artifacts:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
}
},
"remoteCache": { "enabled": true, "signature": true }
}
On Turborepo v1 the same block lives under pipeline instead of tasks.
Make keys stable, declare inputs precisely, and populate the cache from trusted builds:
// turbo.json — declared inputs and outputs make a task cacheable and stable
{
"tasks": {
"build": { "dependsOn": ["^build"], "env": ["NODE_ENV"], "outputs": ["dist/**"] }
}
}
# main-branch build writes the shared cache; PRs read it
export TURBO_TOKEN=... TURBO_TEAM=...
pnpm turbo run build test --filter='...[origin/main]'
Pin toolchain versions so keys do not drift between runners, and cache the package-manager store so installs are fast even on a cache miss.
Measuring the hit rate over time
A single run tells you little; throughput problems show up as a trend. Capture the hit/miss split from each CI build as a one-line metric you can chart, so a regression in cache effectiveness is visible the day a bad inputs glob lands rather than weeks later when the bill arrives.
# Emit a single summary line per CI build for log-based dashboards
turbo run build --dry=json | jq -r '
[.tasks[] | .cache.status] as $s
| "cache_hit_rate=\((($s | map(select(. == "HIT")) | length) * 100) / ($s | length))"
'
A healthy pull-request build against an unchanged graph should report a hit rate above 90%. A persistent dip into the 50–70% range is the signature of hash drift — some input is varying between the run that populated the cache and the run reading it. Feed that suspicion straight into the diff in step 1.
Two numbers explain almost every slow-but-working cache. The first is the artifact size per task: a build task that should emit a few hundred kilobytes of dist/ but uploads tens of megabytes is dragging volatile directories into outputs. The second is upload wall-time relative to compile time: when uploads take longer than the work they cache, you are network-bound, and the fix is compression and concurrency tuning rather than more inputs surgery.
Interpreting --summarize output
Turborepo can write a machine-readable run summary that records, per task, the resolved hash, the cache status, and the timing. This is the most reliable source for "why did this miss," because it shows the exact hash the runner computed rather than what you assume it computed.
# Write .turbo/runs/<id>.json with full hash and timing detail
turbo run build --summarize
# Pull the inputs that contributed to one task's hash
jq '.tasks[] | select(.taskId=="@app/web#build") | {hash, cacheStatus: .cache, inputs: .hashOfExternalDependencies}' \
.turbo/runs/*.json
Compare the hash field across a local run and a CI run of the same commit. If they differ, the summary's input breakdown narrows the search to the offending file or variable in a single pass.
CLI validation
# Confirm hits after warming — status should read "HIT" for unchanged packages
turbo run build --dry=json | jq '.tasks[] | {id: .taskId, status: .cache.status}'
# Watch live cache decisions during a real run
turbo run build --log-order=stream --output-logs=hash-only
Required CI environment
| Variable | Value | Purpose |
|---|---|---|
TURBO_TOKEN |
masked secret | Auth token for the remote cache handshake |
TURBO_TEAM |
team slug | Shared cache namespace |
TURBO_REMOTE_CACHE_SIGNATURE_KEY |
masked secret | Verifies artifact signatures |
CI |
true |
Forces deterministic CI behavior |
Cache warming as a scheduled job
The most effective single optimization for a busy repo is to keep the cache hot on the default branch so no contributor ever pays for a cold build. A scheduled workflow that runs the full graph on main writes every current artifact; subsequent pull-request builds then read those artifacts for any package they did not touch.
# .github/workflows/cache-warm.yml
name: cache-warm
on:
schedule:
- cron: '0 * * * *' # hourly; tighten around peak merge windows
push:
branches: [main]
jobs:
warm:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec turbo run build test --concurrency=10
Because this job runs on a protected branch it is allowed to write, while pull requests stay read-only. The cost is a handful of full builds per day; the saving is that every merge-time CI run starts warm.
Prevention and CI guardrails
- Pin the Node.js version in
setup-nodeso the runner version never enters the hash unexpectedly. - Keep
.turbo/,node_modules/,*.log, and.env.*out of bothinputsand version control. - Restore
.turbobefore every build and scope every run with--filter. - Run the scheduled
mainbuild above to warm the cache ahead of high-traffic merge windows. - Sign artifacts and run pull requests read-only to keep optimization from widening the attack surface.
- Chart the per-build hit rate so a determinism regression surfaces the day it lands.
- Declare
env,inputs, andoutputsprecisely so keys are stable and complete. - Populate the cache from trusted branch builds; keep PR builds read-only.
- Pin Node and package-manager versions so keys do not drift between runners.
- Track the hit rate over time so a key-destabilizing change is caught early.
Measuring and improving the cache hit rate
A remote cache that is configured but not measured can quietly underperform, so the hit rate is the metric that 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 rate: a task that always misses has an unstable input — an embedded timestamp, an undeclared env var, a non-deterministic build — 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, on the pull request that caused it.
Improving the rate 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 populating the cache from trusted branch builds ensures the results are there to hit. A well-tuned remote cache is often the single largest speed-up available to a mature monorepo CI, because it turns work done once — on any runner, by anyone — into work reused everywhere, so the effort spent stabilizing keys and declaring inputs pays back on every subsequent build.
Optimizing installs and the fixed CI overhead
Even with a perfect task cache, CI pays a fixed per-job overhead — checkout, dependency install, runner startup — that caching the task outputs does not reduce, so optimizing that floor is where the remaining wins live. Caching the package-manager store, keyed on the lockfile hash, turns a cold dependency download into a fast linking step on subsequent runs, so a cache-missed build still installs quickly. This matters because once the build work is optimized away by the remote cache, the install often becomes the dominant cost.
The install should also be locked down as part of this optimization: a frozen lockfile so the resolved graph is exactly the reviewed one, and ignored scripts so a compromised dependency cannot execute during install. These are reproducibility measures as much as security ones — a frozen, script-free install produces the identical tree every run, which keeps the task cache keys stable across CI and local. Between a warm store, a fast frozen install, and a well-tuned task cache, the pipeline approaches a floor set only by the unavoidable fixed overhead, which is the practical limit of how fast a monorepo CI can be made.
Securing the shared cache in CI
A remote cache optimized for CI is a shared, mutable, replayed resource, which makes it a trust boundary that deserves the same threat modeling as any such resource. Because a cache hit replays a stored artifact verbatim, an attacker who can write a poisoned entry injects a compromised build into every runner that 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, and include the lockfile in the key so a dependency change cannot replay an artifact built against the old graph.
Install-time safety applies to the builds that populate the cache too. Running installs with ignored scripts on the runner prevents a compromised dependency from executing during the build that writes a cache entry, so the artifacts other runs replay were produced by a controlled process. Combined with a frozen lockfile, this means the cache is populated only from reviewed, reproducible builds on trusted branches, and consumed safely everywhere. Optimizing the cache for speed and securing it are therefore the same exercise: the precise input declarations that make keys stable also make them hard to collide maliciously, and the trusted-write policy that keeps the cache clean is what lets you rely on a hit being genuinely equivalent to a rerun.
Frequently Asked Questions
Why does Turborepo miss in CI even after a successful local build?
The runner derives a different task hash because the CI environment differs in OS, Node.js version, or a volatile file that leaked into inputs. Diff the --dry=json hashes from both machines, then pin or remove whatever input differs.
How do I stop uploads from timing out under high concurrency?
Raise --remote-cache-timeout to give large artifacts room to finish, and lower --concurrency so parallel uploads do not saturate the network link; the two settings trade off against each other.
Does restoring .turbo from actions/cache conflict with the remote cache?
No. The local .turbo restore is checked first and avoids the network entirely on a hit; the remote cache is the fallback when the local cache is cold, so the two layers complement each other.
Why is my remote cache hit rate low in CI?
Either the keys are unstable (an embedded timestamp or undeclared env var makes every run miss) or the cache is not populated (only read-only PR builds run, so nothing writes it). Stabilize keys by declaring inputs, and populate the cache from trusted branch builds.
How do I populate a remote cache that PRs can read?
Let trusted branch builds (main) write the shared cache, and keep pull-request builds read-only against it. The results computed on main are then available for later runs, including PRs, without a pull request being able to poison the cache.
What's left to optimize once the task cache is warm?
The fixed per-job overhead — checkout and install. Cache the package-manager store keyed on the lockfile so installs are fast even on a task-cache miss, and use a frozen install so the tree is reproducible and the keys stay stable across runs.
Is a shared remote cache a security risk in CI?
It can be — a cache hit replays a stored artifact verbatim, so 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 populate the cache from frozen, script-free installs.
Related
- Remote Caching Setup — how the cache key is derived and how to secure the shared store.
- Fixing Turborepo Remote Cache Misses — when the cache returns errors or never hits at all, start here before tuning.
- Self-Hosting a Turborepo Remote Cache — control compression and retention end-to-end behind your own object store.