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:
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.enabledgates the feature globally; set it here once rather than via CLI flags.outputsmust enumerate every directory or glob the task writes β omissions cause cache bloat (untracked files accumulate) or false misses.envlists 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 $FLAGSFor 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-onlyVerification: 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-onlyGitLab 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_APIorTURBO_TOKENis not set, causing silent fallback to local-only mode. - Gradual decline week-over-week:
outputsdeclarations 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:
- Confirm the secret is set in the CI environment:
echo "TOKEN SET: $([ -n "$TURBO_TOKEN" ] && echo yes || echo no)". - Verify the token scope includes the team in
TURBO_TEAMby querying the cache server directly:curl -s -H "Authorization: Bearer $TURBO_TOKEN" \ "${TURBO_API}/v8/artifacts/status" | jq . - 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:
- Add a connectivity check before the build:
curl -sf --max-time 5 "$TURBO_API" || echo "cache unreachable, continuing local". - Reduce
--concurrencyto limit simultaneous upload/download requests. - Implement graceful degradation: Turborepo continues with local execution on timeout β ensure
--remote-onlyis not set for non-main-branch jobs, so a cache outage does not block PRs. - 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:
- Enable verbosity and capture the hash inputs:
npx turbo run build --verbosity=2 2>&1 | grep -A 20 "β’ Packages in scope". - Compare hash lines across two runs and identify the differing input.
- Pin Node.js via
.nvmrcand enforce it in CI:- uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" - Audit the
envarray inturbo.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:
- Run
npx turbo run build --forceto regenerate without cache, then compare the restored artifact with a fresh build artifact usingdiff -r dist/ cached-dist/. - Tighten
outputsto the exact paths the task writes: preferdist/over**. - 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.
Related
- Incremental Builds and Affected Detection in Monorepos β scope Turborepo task execution to only the packages changed by a commit, dramatically reducing remote cache traffic.
- Docker Layer Caching for Full-Stack Applications β align Turborepo output paths with Docker
COPYlayers so both cache layers hit on the same build. - Optimizing Webpack and Vite for CI Environments β complement remote task caching with bundler-level persistence cache configuration.
- Fixing Cache Poisoning Issues in Distributed CI Runners β incident response playbook for stale or corrupted remote cache artifacts.
β Back to Build Optimization & Caching Strategies