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

Fixing Turborepo Remote Cache Misses

Your local machine gets instant cache hits, but every CI run reports cache miss, executing and rebuilds from scratch. Remote cache misses almost always come from a task hash that differs between environments: an environment variable that leaks into the build, a glob that captures a non-deterministic file, a lockfile mismatch, or a misconfigured TURBO_TOKEN/TURBO_TEAM. This page shows how to read the hash inputs with --dry=json and --summarize, then eliminate each source of drift.

Symptoms

Symptoms The first block is the costly case: every task is a miss even though nothing meaningful changed. Symptoms The first block is the costly case: every task is a miss even though nothing meaningful changed.
Symptoms — the core idea of this section at a glance.
• Packages in scope: web, ui, utils
• Running build in 3 packages
ui:build: cache miss, executing 7c9e1f2a3b4d5e6f
web:build: cache miss, executing a1b2c3d4e5f6a7b8

 Tasks:    3 successful, 3 total
Cached:    0 cached, 3 total
  Time:    1m48s
WARNING  failed to contact remote cache: 403 Forbidden
WARNING  Remote caching is disabled because no token was found.

The first block is the costly case: every task is a miss even though nothing meaningful changed. The second shows authentication failing outright, so Turborepo silently falls back to local-only caching.

Root cause

Turborepo computes a SHA-256 hash for every task from a precise set of inputs: the hashed contents of the task's input files, the resolved dependency set from the lockfile, the task's outputs declaration, the values of any environment variables it depends on, globalDependencies, and the turbo.json config itself. A remote cache hit requires that the hash computed on this machine matches a hash already stored in the remote cache. Any input that differs between your laptop and a CI runner produces a different hash and therefore a miss. Because Remote Caching Setup keys artifacts on that hash, an unstable input quietly defeats the entire cache. The most common culprits are environment variables that are present in CI but not locally, lockfile differences, and outputs that embed timestamps or absolute paths.

Diagnosing a remote cache miss A decision flow that checks token, then hash inputs, then output determinism to locate the cause of a cache miss. cache miss reported run --dry=json token / team set? no → fix auth hashes match? no → diff inputs deterministic outputs → hit
Work top to bottom: rule out auth, then input drift, then non-deterministic outputs.

A Turborepo cache miss means at least one input that fed a task's hash differed from any previous run whose result was stored. The hash mixes the task's source files, its resolved dependencies, the task configuration, and any declared environment variables, so an unexpected miss traces to one of those diverging — most commonly an undeclared environment variable whose value changed, an unpinned tool version, or an absolute path leaking into an input. The cache is working correctly; it is telling you an input changed.

The dangerous inverse is a task that reads an input Turborepo does not know about — an undeclared env var, an implicit file — because then two genuinely-different runs can produce the same key and one replays the other's stale artifact. So diagnosing cache misses is really about making the hash reflect reality: every input that affects the output must be declared, so the hash changes exactly when the output would and stays the same when it would not.

Resolution

1. Confirm authentication and team

Resolution A 403 or "no token was found" means Turborepo never reached the remote cache. Resolution A 403 or "no token was found" means Turborepo never reached the remote cache.
Resolution — the core idea of this section at a glance.

A 403 or "no token was found" means Turborepo never reached the remote cache. Set both the token and the team slug; the team must match the cache namespace:

export TURBO_TOKEN=your_cache_token
export TURBO_TEAM=your-team-slug
turbo run build --remote-only --summarize

--remote-only forces Turborepo to ignore the local .turbo cache so you can prove the remote layer works in isolation.

2. Dump the hash inputs

--dry=json prints exactly what went into each task hash without executing anything. Run it in both environments and diff the output:

turbo run build --dry=json > local-hashes.json
# On CI, capture the same and compare
turbo run build --dry=json > ci-hashes.json

Each task entry contains hash, inputs (file → content hash), hashOfExternalDependencies, and the resolved envMode and environment variable list. The first field that differs between the two files is your culprit.

3. Declare the environment variables a task depends on

Strict env mode (the default) means a task only sees the env vars it declares. If a build reads NODE_ENV or API_URL and you have not declared it, the value cannot change the hash on CI — but if the framework auto-detects it, the output changes while the hash does not, which corrupts the cache. Declare every meaningful variable:

{
  "globalEnv": ["NODE_ENV", "CI"],
  "tasks": {
    "build": {
      "env": ["API_URL", "NEXT_PUBLIC_*"],
      "inputs": ["$TURBO_DEFAULT$", "!**/*.test.ts"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    }
  }
}

4. Pin inputs, outputs, and global dependencies

A glob that matches a log file, a coverage report, or a .DS_Store will change the hash on every run. Scope inputs tightly and exclude generated files. List shared root files in globalDependencies so a change to them invalidates everything intentionally rather than randomly:

{
  "globalDependencies": ["tsconfig.base.json", ".env.production"],
  "tasks": {
    "build": {
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    }
  }
}

5. Make outputs deterministic

If two builds of identical source produce byte-different output (embedded timestamps, absolute paths, randomized chunk hashes), the stored artifact is fine but downstream tasks that consume it will miss. Strip timestamps, set SOURCE_DATE_EPOCH, and avoid absolute paths in generated files. Ensure the lockfile is identical across environments, exactly as in Lockfile Management Strategies, because hashOfExternalDependencies is derived directly from it.

Read the hash inputs to find the divergence, then declare it:

# Print each task's hash and the inputs that fed it
pnpm turbo run build --dry=json | jq '.tasks[] | {package, hash, inputs: .inputs | keys}'
// turbo.json — declare the env and file inputs that were missing
{
  "tasks": {
    "build": { "env": ["API_URL", "NODE_ENV"], "inputs": ["src/**", "tsconfig.json"], "outputs": ["dist/**"] }
  }
}

Diffing the dry-run inputs between a hitting and a missing run names the divergent input; declaring it in env/inputs makes the hash stable so the cache hits when it should.

Validation

After applying fixes, prove the cache is shared end to end:

Validation After applying fixes, prove the cache is shared end to end: Validation After applying fixes, prove the cache is shared end to end:
Validation — the core idea of this section at a glance.
# Machine A: populate the remote cache
turbo run build --remote-only

# Machine B (or a clean CI runner): should replay, not rebuild
turbo run build --remote-only --summarize
cat .turbo/runs/*.json   # inspect cacheStatus for each task

A successful run reports cache hit, replaying logs and the summary's cacheStatus.timeSaved is non-zero.

CI guardrails

  • Set TURBO_TOKEN and TURBO_TEAM as CI secrets; verify with a --summarize step that fails the job if cacheStatus is all-miss on a no-op commit.
  • Commit turbo.json inputs/outputs/env declarations and review them like code; an undeclared env var is a latent cache bug.
  • Pin the package manager and lockfile so hashOfExternalDependencies is stable across runners.
  • Add a --dry=json artifact upload so a regression in hashing is debuggable from the CI logs.
  • Use a read-only cache token for untrusted fork PRs to prevent cache poisoning.
CI guardrails CI guardrails in production JavaScript package workflows. CI guardrails CI guardrails in production JavaScript package workflows.
CI guardrails — the core idea of this section at a glance.

Distinguishing a correct miss from a broken key

Not every cache miss is a problem — a miss on a genuine code change is exactly right. The misses worth investigating are the unexpected ones: a task that misses when nothing it depends on changed, or one that misses in CI but hits locally. Both point at an input the hash is treating as changed when it should be stable, and the diagnosis is to read the hash inputs and find the one that differs. A differing hash with identical source points at an environment input; differing source points at a real change or a path leak.

Miss or bug Whether a cache miss is correct or a broken key. Did anything the task depends on change? yes correct miss unexpected unstable input hits when it shouldn't undeclared input
A miss on a real change is right; a miss with no change means an unstable input.

The opposite failure — a task that hits when it should have missed — is rarer but more dangerous, because it silently ships a stale artifact. This happens when a task reads an input the hash does not capture: an undeclared env var, a file outside the declared inputs, a tool whose version affects output but is not pinned. Preventing it is why declaring inputs precisely matters as much as fixing misses. A cache you can trust is one where the hash changes exactly when the output would — no more (which wastes the cache on false misses) and no less (which replays staleness). Reading the dry-run output is how you verify the hash has that property for a given task.

Keeping cache keys stable across CI and local

The most common unexpected miss is a task that hits on a developer's machine but misses in CI, which always traces to a hashed input that differs between the two environments. Pinning the toolchain — Node via .nvmrc or Volta, the package manager via packageManager and Corepack, build tools via the lockfile — removes version differences, and declaring every environment variable a task reads means a variable set locally but not in CI cannot silently change the key.

Stable keys Pin the toolchain, declare env, diff the hash. pin toolchain no version drift declare env inputs no silent skew diff hashes find divergence
Stable keys let a result computed anywhere be reused everywhere.
# Compare hash inputs between environments
pnpm turbo run build --dry=json | jq '.tasks[0].hash'

Run that locally and in a CI debug job, and diff the results: an identical hash means the cache will share, and a differing one names the environment to investigate. The goal is a hash that reflects only genuine differences — real code and dependency changes — and ignores incidental ones like a tool-version mismatch between machines. When keys are stable across environments, a result computed on any runner or laptop is reused everywhere, which is the whole value of a remote cache. An unstable key that diverges between CI and local delivers a cache that is configured but never shares, which is worse than no cache because it adds overhead without the payoff.

Frequently Asked Questions

Why do I get cache hits locally but misses in CI? The task hash differs between the two environments. The usual causes are an environment variable present in CI but not locally, a different lockfile resolution, or an input glob that captures a file CI generates. Run turbo run build --dry=json in both places and diff the inputs and env fields to find the first divergence.

What does "no cache hit" actually mean in Turborepo? It means Turborepo computed a task hash that does not exist in the cache it is reading, so it executes the task. It is not an error; it simply indicates the inputs to that task changed (or appear to have changed) since the last cached run.

How do I see exactly what went into a task hash? Use turbo run <task> --dry=json to print the resolved inputs, external dependency hash, and environment variables per task, and --summarize to write a per-run JSON summary under .turbo/runs/. Comparing these across machines pinpoints the unstable input.

Does a different environment variable always cause a miss? Only if the task declares that variable in env/globalEnv. The subtler failure is the reverse: an undeclared variable that changes the build output but not the hash, which stores a stale artifact under a hash that no longer matches reality. Declare every variable the build actually reads.

Why does my Turborepo task miss the cache when nothing changed?

A hashed input differs — usually an undeclared env var whose value changed, an unpinned tool version, or an absolute path leaking in. Read the hash inputs with --dry=json, find the one that differs, and declare it in env/inputs so the hash stays stable.

Why does a task hit locally but miss in CI?

An input differs between the environments — commonly a tool version or an env var set locally but not in CI. Pin the toolchain and declare every env var the task reads, then diff the --dry=json hashes between environments to find the divergence.

Can a cache hit when it shouldn't?

Yes, if a task reads an input the hash doesn't capture — an undeclared env var or an implicit file. Then a genuinely-different run replays a stale artifact. Declaring every input precisely is what prevents this false hit, which is more dangerous than a false miss.

Does globalDependencies affect cache misses?

Yes — a file in globalDependencies (a base tsconfig, a root config) is an input to every task, so changing it misses the cache for everything. That is correct when the file genuinely affects all output; keep the list minimal so an unrelated root file does not cause workspace-wide misses.

How do I confirm two runs will share the cache?

Print each run's task hash with turbo run <task> --dry=json | jq '.tasks[].hash' and compare — identical hashes will share, differing ones will not. Diffing the inputs behind a differing hash names exactly what to declare or pin.

Related

Remote Caching Setup