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