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

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.

CI cache decision flow A CI task checks the local cache, then the remote cache, executing and uploading only on a full miss. task hash computed local .turbo? restored by key remote cache? download tar HIT skip work MISS run + upload
Each CI task tries the local cache, then the remote cache, and only executes and uploads on a full miss.

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-node so the runner version never enters the hash unexpectedly.
  • Keep .turbo/, node_modules/, *.log, and .env.* out of both inputs and version control.
  • Restore .turbo before every build and scope every run with --filter.
  • Run the scheduled main build 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