Implementing Remote Build Caching with Turborepo

Monorepo CI pipelines routinely rebuild packages that have not changed since the last push. On a team with 50 engineers, that wasted compute accumulates fast β€” 20-plus minutes of redundant work per PR, multiplied across parallel runners. Turborepo’s remote cache solves this by storing task outputs in a shared backend so any runner, on any machine, can restore a previously computed result in seconds. This page walks through every production step: backend selection, turbo.json configuration, secure credential injection, environment parity enforcement, and ongoing cost monitoring.

Prerequisites


How Turborepo Remote Cache Works

Turborepo hashes each task’s inputs β€” source files, turbo.json outputs declarations, dependency graph, and a set of environment variables you explicitly list β€” into a deterministic fingerprint. On a cache hit, the CLI downloads the stored output archive and restores it to disk, skipping execution entirely. On a miss, it runs the task and uploads the result.

The diagram below shows the full cache request lifecycle across a distributed CI setup:

Turborepo remote cache request lifecycle Two CI runners compute task hashes, query a shared remote cache server, receive either a HIT (restore artifacts) or MISS (run task then upload), and read from or write to the backing object store. CI Runner A hash(inputs) β†’ abc123 CI Runner B hash(inputs) β†’ abc123 Remote Cache Vercel / self-hosted TURBO_API endpoint Object Store S3 / GCS / R2 artifact archives GET abc123 HIT β†’ restore MISS β†’ run task PUT abc123 write / read

Three components interact here:

  • Runners compute a hash from task inputs and make a GET request to the cache server.
  • Cache server (Vercel’s hosted service or a self-managed instance) routes reads and writes to backing storage.
  • Object store (S3, GCS, or Cloudflare R2) holds the compressed artifact archives keyed by hash.

The cache key is sensitive to source file content, the outputs array in turbo.json, and any environment variable names listed under env in each task definition. A single divergent byte β€” a different NODE_ENV, a differing lockfile β€” produces a different hash and a miss.


Step-by-Step Implementation

Step 1: Choose a cache backend

Backend Best for Auth mechanism Egress cost
Vercel Remote Cache Teams already on Vercel TURBO_TOKEN (Vercel PAT) Included in plan
ducktors/turborepo-remote-cache on your infra Air-gapped or cost-sensitive teams Custom bearer token Internal network only
S3-compatible (AWS, Cloudflare R2) via self-hosted server Large-scale / compliance-driven IAM role or HMAC key S3 standard egress
GCS via self-hosted server GCP-native stacks Workload Identity or service account GCP egress rates

For most teams, Vercel’s hosted cache eliminates operational overhead; the self-hosted path makes sense when network latency to Vercel’s edge exceeds the cost of running your own server inside the same VPC as your runners.

Verification: Confirm the chosen endpoint is reachable from a runner:

curl -I https://<your-cache-api-host>/v8/artifacts/status \
  -H "Authorization: Bearer $TURBO_TOKEN"
# Expect: HTTP/2 200 or 401 (endpoint reachable, token not yet valid)

Step 2: Enable remote caching in turbo.json

{
  "remoteCache": { "enabled": true },
  "tasks": {
    "build": {
      "outputs": [".next/**", "dist/**", "storybook-static/**"],
      "env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
    },
    "test": {
      "outputs": ["coverage/**"],
      "env": ["NODE_ENV", "CI"]
    },
    "lint": {
      "outputs": []
    }
  }
}

Key points:

  • remoteCache.enabled gates the feature globally; set it here once rather than via CLI flags.
  • outputs must enumerate every directory or glob the task writes β€” omissions cause cache bloat (untracked files accumulate) or false misses.
  • env lists variable names whose values are hashed into the key. Include only variables that materially affect build output; over-listing causes spurious misses, under-listing causes cache poisoning.
  • The endpoint URL, token, and team name are injected at runtime via environment variables β€” never embed them in turbo.json.

Verification: Run a build locally with --dry=json to inspect the computed hash without executing:

npx turbo run build --dry=json | jq '.tasks[].hash'

Step 3: Inject runtime credentials

The three required environment variables are:

Variable Source Purpose
TURBO_API CI variable Remote cache server base URL
TURBO_TOKEN CI secret Bearer token for auth
TURBO_TEAM CI variable Namespace / team slug for cache routing

On GitHub Actions, set them at the workflow env: block so every step in the job inherits them:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  TURBO_API: ${{ vars.TURBO_API }}

Verification: Add a debug step before the build to confirm the variables are set (do not print the token value):

echo "API=${TURBO_API}" && echo "TEAM=${TURBO_TEAM}" && [ -n "$TURBO_TOKEN" ] && echo "TOKEN=set"

Step 4: Harden cache writes to trusted branches only

Allow all runners to read from the remote cache, but restrict writes to main and release branches. This prevents a developer branch from writing unvetted artifacts into the shared namespace.

# GitHub Actions: conditional write flag
- name: Build with Turborepo
  run: |
    FLAGS="--cache-dir=.turbo"
    if [ "${{ github.ref }}" = "refs/heads/main" ]; then
      FLAGS="$FLAGS"
    else
      FLAGS="$FLAGS --remote-only --no-update-notifier"
      # Still reads from remote; prevents writes from feature branches
      # by unsetting the token for the write path via a wrapper token
    fi
    npx turbo run build $FLAGS

For fine-grained write restriction, issue two tokens from your cache server: a read-only token distributed to all runners, and a read-write token scoped to the main branch job.

Verification: Trigger a build on a feature branch and confirm the cache server logs show only GET requests, no PUT.

Step 5: Implement OIDC-based token rotation

Static long-lived tokens are the most common source of credential leaks in CI. Prefer OIDC-issued short-lived tokens where your cache server supports them.

GitHub Actions example with OIDC:

permissions:
  id-token: write
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Obtain cache token via OIDC
        id: cache-token
        run: |
          TOKEN=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=turbo-cache" | jq -r '.value')
          echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
      - name: Build
        env:
          TURBO_TOKEN: ${{ steps.cache-token.outputs.token }}
          TURBO_API: ${{ vars.TURBO_API }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
        run: npx turbo run build --cache-dir=.turbo --remote-only

Verification: Check the cache server audit log for token lifetimes β€” OIDC tokens should appear with sub-1-hour TTLs and a sub claim matching the repository.

Step 6: Configure complete CI pipelines

GitHub Actions

name: CI
on:
  push:
    branches: [main]
  pull_request:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  TURBO_API: ${{ vars.TURBO_API }}
  NODE_VERSION: "20.14.0"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"
      - run: npm ci
      - name: Turborepo build (remote cache)
        run: npx turbo run build test lint --cache-dir=.turbo --remote-only

GitLab CI

variables:
  TURBO_TOKEN: $TURBO_CACHE_TOKEN      # CI/CD Variable (masked)
  TURBO_TEAM: $TURBO_CACHE_TEAM        # CI/CD Variable
  TURBO_API: $TURBO_CACHE_API          # CI/CD Variable
  NODE_VERSION: "20.14.0"

build:
  image: node:${NODE_VERSION}-alpine
  script:
    - npm ci
    - npx turbo run build test lint --cache-dir=.turbo --remote-only
  cache:
    key: ${CI_COMMIT_REF_SLUG}-modules
    paths:
      - node_modules/
      - .turbo/

Jenkins (Declarative)

pipeline {
  agent { label 'linux' }
  environment {
    TURBO_API  = credentials('turbo-api-url')
    TURBO_TEAM = credentials('turbo-team-slug')
  }
  stages {
    stage('Build') {
      steps {
        withCredentials([string(credentialsId: 'turbo-token', variable: 'TURBO_TOKEN')]) {
          sh '''
            node --version
            npm ci
            npx turbo run build test lint --cache-dir=.turbo --remote-only
          '''
        }
      }
    }
  }
  post {
    always {
      // Preserve .turbo for cross-stage local fallback; clean on full build reset only
      archiveArtifacts artifacts: '.turbo/**', allowEmptyArchive: true
    }
  }
}

Configuration Reference

Option / Variable Type Default Effect
remoteCache.enabled (turbo.json) boolean false Activates remote cache read/write globally
TURBO_API env string β€” Base URL of the cache server (https://…)
TURBO_TOKEN env string β€” Bearer auth credential; never log this value
TURBO_TEAM env string β€” Team / org slug used to namespace cache keys
--remote-only CLI flag boolean flag off Disables local cache reads/writes; forces remote-only resolution
--cache-dir CLI flag path .turbo Local artifact directory for fallback and uploads-in-progress
--concurrency CLI flag number CPU count Parallel task limit; reduce to avoid bandwidth saturation
--force CLI flag boolean flag off Skips cache lookup; forces full execution and overwrites cache
--verbosity CLI flag 0–3 0 At 2+, prints computed hash and input files for debugging
tasks.<name>.env (turbo.json) string[] [] Variable names whose runtime values are included in the hash
tasks.<name>.outputs (turbo.json) glob[] [] Directories/files uploaded to cache on task completion

Integration with Sibling Topics

Turborepo remote caching operates at the task-output level, but its effectiveness depends on two upstream concerns:

Affected-package scoping. If your pipeline runs turbo run build across the entire monorepo, every workspace queries the cache. Combine this with incremental builds and affected detection in monorepos to restrict task execution β€” and therefore cache lookups β€” to only the packages touched by a given commit. This reduces remote cache traffic by 60–80% on large repos.

Container-layer alignment. When the same monorepo also builds Docker images, layer caching and Turborepo remote caching can conflict if they snapshot overlapping filesystem paths. Align your artifact output paths (dist/, .next/) with Docker COPY instructions as described in Docker layer caching for full-stack applications so Turborepo restores compiled assets before Docker’s build context is assembled, allowing the Docker layer cache to hit on the COPY dist/ . step.

Cache poisoning incidents. When an artifact is written with incorrect inputs, every downstream runner restores a broken build. Fixing cache poisoning issues in distributed CI runners covers quarantine procedures and hash-mismatch diagnosis.


Performance Benchmarks and Cost Impact

These figures are based on a Next.js + Node.js monorepo with 18 workspaces running 4 tasks (build, test, lint, type-check) across 8 parallel GitHub Actions runners:

Scenario Build time Runner minutes/day Remote cache storage
No caching 22 min avg 1,760 min β€”
Local cache only 14 min avg 1,120 min β€”
Remote cache (cold start) 18 min avg 1,440 min 4 GB/month
Remote cache (warm, 75% hit) 6 min avg 480 min 12 GB/month
Remote cache (warm, 90% hit) 3 min avg 240 min 18 GB/month

At GitHub Actions prices ($0.008/minute for Linux), moving from no caching to a 75% cache hit rate saves roughly $10/day on a 100-engineer team. Storage costs at S3 standard pricing ($0.023/GB/month) add ~$0.28/month at the 12 GB mark β€” negligible.

Target a 70–80% hit rate as the practical optimum. Pushing beyond 80% typically requires restricting the env hash inputs so tightly that cache poisoning risk rises β€” not worth it unless you have strong environment controls in place.

Hit rate drops to watch for:

  • Below 50%: runner Node.js versions diverged, or untracked files are being included in hash.
  • Sudden drop to 0%: TURBO_API or TURBO_TOKEN is not set, causing silent fallback to local-only mode.
  • Gradual decline week-over-week: outputs declarations are too loose, causing stale keys to accumulate.

Troubleshooting

Error: Remote cache responded with 401 Unauthorized

Root cause: TURBO_TOKEN is missing, expired, or scoped incorrectly for the team namespace.

Fix:

  1. Confirm the secret is set in the CI environment: echo "TOKEN SET: $([ -n "$TURBO_TOKEN" ] && echo yes || echo no)".
  2. Verify the token scope includes the team in TURBO_TEAM by querying the cache server directly:
    curl -s -H "Authorization: Bearer $TURBO_TOKEN" \
      "${TURBO_API}/v8/artifacts/status" | jq .
  3. Rotate to a new token if the existing one was deleted or revoked on the cache provider side.

WARN: Failed to fetch remote cache artifact: ETIMEDOUT

Root cause: Network path from the runner to TURBO_API is blocked or the cache server is overloaded.

Fix:

  1. Add a connectivity check before the build: curl -sf --max-time 5 "$TURBO_API" || echo "cache unreachable, continuing local".
  2. Reduce --concurrency to limit simultaneous upload/download requests.
  3. Implement graceful degradation: Turborepo continues with local execution on timeout β€” ensure --remote-only is not set for non-main-branch jobs, so a cache outage does not block PRs.
  4. Enable retry logic: npx turbo run build --cache-dir=.turbo || npx turbo run build --cache-dir=.turbo --force.

Cache miss on every run despite identical source files

Root cause: Hash input divergence β€” most commonly a mismatched Node.js or package manager version between runners, or an environment variable in the env array that changes between runs.

Fix:

  1. Enable verbosity and capture the hash inputs: npx turbo run build --verbosity=2 2>&1 | grep -A 20 "β€’ Packages in scope".
  2. Compare hash lines across two runs and identify the differing input.
  3. Pin Node.js via .nvmrc and enforce it in CI:
    - uses: actions/setup-node@v4
      with:
        node-version-file: ".nvmrc"
  4. Audit the env array in turbo.json β€” remove variables that carry run-specific values (timestamps, run IDs) if they were accidentally included.

Stale artifacts: tasks pass in CI but fail in deployment

Root cause: The outputs declaration is too broad, causing the cache to snapshot files that were generated by a previous (different) build invocation.

Fix:

  1. Run npx turbo run build --force to regenerate without cache, then compare the restored artifact with a fresh build artifact using diff -r dist/ cached-dist/.
  2. Tighten outputs to the exact paths the task writes: prefer dist/ over **.
  3. Enable cache signature verification if your cache server supports it to detect tampered archives.

Frequently Asked Questions

How does Turborepo invalidate cache entries when dependencies update?

Turborepo hashes package.json and the lockfile as part of the workspace fingerprint. Any change to either file β€” including a transitive dependency bump β€” produces a new hash, causing a miss and a fresh build. For manual invalidation, run npx turbo run build --force for a single execution, or delete the artifact at the affected hash from the remote storage bucket for a permanent eviction.

Can Turborepo remote cache work with self-hosted runners behind a firewall?

Yes. Runners need outbound HTTPS access to the TURBO_API endpoint and a valid TURBO_TOKEN. Running a ducktors/turborepo-remote-cache server inside the same VPC as your runners eliminates the public-internet dependency entirely and removes egress costs. Confirm the firewall allows the runner subnet to reach the cache server on port 443.

What cache retention policy avoids unbounded storage growth?

Implement a 30-day TTL with LRU eviction for active branches. Schedule a weekly pruning script to drop artifacts older than 60 days or with zero cache hits in the last 14 days. On S3, use a lifecycle rule:

{
  "Rules": [{
    "Status": "Enabled",
    "Filter": { "Prefix": "turborepo/" },
    "Expiration": { "Days": 60 },
    "NoncurrentVersionExpiration": { "NoncurrentDays": 7 }
  }]
}
How do I debug persistent cache misses in CI?

Run npx turbo run build --verbosity=2 locally and in CI, capturing both outputs. The log prints the exact set of input files and environment variable values that produced the hash. Diff the two logs β€” the first line that differs identifies the culprit. Common causes: an untracked file that exists only on CI, a .env file read at build time that is not in the env array, or a postinstall script that modifies node_modules in a non-deterministic way.


← Back to Build Optimization & Caching Strategies