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

Fixing Turborepo Cache Not Shared Between CI and Local

Local builds hit the cache but CI always misses (or vice versa), so the shared remote cache delivers no speed-up. This page shows why the hash differs between environments and how to make cache keys portable.

Exact symptoms and error messages

Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows. Exact symptoms and error messages Exact symptoms and error messages in production JavaScript package workflows.
Exact symptoms and error messages — the core idea of this section at a glance.
# locally
cache hit, replaying logs 3f9a2b...
# in CI, same commit
cache miss, executing 8c11d4...

Root cause analysis

A Turborepo cache key is a hash of task inputs — source files, dependencies, declared env vars, and the resolved graph. If any input differs between environments (an env var present locally but not in CI, a different lockfile, or an absolute path leaking in), the hashes diverge and the cache never matches. This is the cache-key derivation described in Remote Caching Setup and the reproducibility concern from lockfile management.

Portable hash Identical inputs on both sides produce a matching key. declared inputs files + env + lock same hash CI == local shared hit replay artifact
A cache is shared only when every hashed input matches across environments.

A Turborepo cache key is a hash of everything that can change a task's output: the package's source files, its resolved dependencies, the task configuration, and any environment variables the task declares. A cache is shared between two environments only when all of those inputs hash identically. A single divergence — an env var present locally but not in CI, a different Node version baked into a script, an absolute path leaking into an input — produces a different key and a guaranteed miss.

Undeclared environment variables are the most common culprit, because they are invisible until they bite. If a task reads API_URL but the config does not list it in env, Turborepo cannot know it is an input and hashes without it — so two environments with different API_URL values produce the same key and could even replay a wrong artifact. Declaring env inputs is therefore both a correctness and a cache-sharing requirement.

A Turborepo cache key is a hash of everything that can change a task's output: the package's source files, its resolved dependencies, the task configuration, and any environment variables the task declares. A cache is shared between two environments only when all of those inputs hash identically. A single divergence — an env var present locally but not in CI, a different Node version baked into a script, an absolute path leaking into an input — produces a different key and a guaranteed miss, so the cache silently delivers no speed-up across environments.

Undeclared environment variables are the most common culprit, because they are invisible until they bite. If a task reads API_URL but the config does not list it in env, Turborepo cannot know it is an input and hashes without it — so two environments with different API_URL values produce the same key and could even replay a wrong artifact. Declaring env inputs is therefore both a correctness and a cache-sharing requirement: it makes the value part of the hash, so identical values hash the same and different values correctly miss.

Resolution and configuration patch

Declare every environment input so both sides hash identically, and connect both to the same remote cache:

Resolution and configuration patch Declare every environment input so both sides hash identically, and connect both to the same remote cache: Resolution and configuration patch Declare every environment input so both sides hash identically, and connect both to the same remote cache:
Resolution and configuration patch — the core idea of this section at a glance.
// turbo.json
{
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "env": ["API_URL"],
      "outputs": ["dist/**"]
    }
  }
}
# both environments authenticate to the same cache
export TURBO_TOKEN=... TURBO_TEAM=...

Declare every environment input so both sides hash identically, and connect both to the same remote cache:

// turbo.json
{
  "globalEnv": ["NODE_ENV"],
  "globalDependencies": [".nvmrc", "tsconfig.base.json"],
  "tasks": { "build": { "env": ["API_URL"], "outputs": ["dist/**"] } }
}
# both environments authenticate to the same cache
export TURBO_TOKEN=... TURBO_TEAM=...

Pin the Node and package-manager versions so a script does not hash differently, keep the lockfile committed so both sides resolve the same graph, and the CI and local hashes converge.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# Compare the hash inputs between environments
pnpm turbo run build --dry=json | jq '.tasks[0].hash'
# Show what turbo considers part of the hash
pnpm turbo run build --summarize

Prevention and CI guardrails

  • Declare all env inputs in env/globalEnv so undeclared vars cannot skew the hash.
  • Pin the same Node and package-manager versions in CI and locally.
  • Keep the lockfile committed so both sides resolve the identical graph.
  • Use --summarize in CI to diff hash inputs when a cache miss is unexpected.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Declare all env inputs in env/globalEnv so undeclared vars cannot skew the hash.
  • Pin the same Node and package-manager versions in CI and locally.
  • Keep the lockfile committed so both sides resolve the identical graph.
  • Use --summarize in CI to diff hash inputs when a cache miss is unexpected.

Reading the hash to find the divergence

When CI and local disagree, do not guess which input diverged — ask Turborepo to show you. The dry-run output includes the hash and the inputs that fed it, so you can diff the two environments directly.

Diff the inputs Print each environment's hash and inputs, then compare. --dry=json hash + inputs compare CI vs local diff the sets find divergence the missing input
Diffing the dry-run inputs names the divergent variable instead of guessing.
# Print the hash and its inputs in each environment
pnpm turbo run build --dry=json | jq '.tasks[] | {package, hash, inputs: .inputs | keys}'

Run it locally and in a CI debug job, then compare. A differing hash with identical source points at an environment input — an undeclared env var or a tool version — while differing source points at a real code difference or a path leak. This turns 'the cache never hits' from a frustrating mystery into a concrete diff of two input sets, which almost always names the divergent variable immediately.

Declaring env and global inputs precisely

A durable fix is to make Turborepo's view of inputs match reality, so nothing that affects output is left out and nothing irrelevant is folded in. Declare per-task env, workspace-wide globalEnv, and any non-source files that influence a build via globalDependencies.

Precise inputs env, globalEnv and globalDependencies represent every real input. task env per-task vars globalEnv workspace-wide vars globalDependencies non-source inputs portable hash CI == local
An accurate input declaration makes the cache both correct and portable.
// turbo.json
{
  "globalEnv": ["NODE_ENV"],
  "globalDependencies": [".nvmrc", "tsconfig.base.json"],
  "tasks": { "build": { "env": ["API_URL"], "outputs": ["dist/**"] } }
}

Listing API_URL under the task's env means its value is part of the hash — so identical values hash the same across environments and different values correctly miss, instead of silently sharing a wrong artifact. Keeping this list accurate is what makes the cache both correct and portable: every real input is represented, so CI and local converge on the same key whenever they truly should.

Diffing the hash to find the divergence

When CI and local disagree, do not guess which input diverged — ask Turborepo to show you. The dry-run output includes each task's hash and the inputs that fed it, so you can compare the two environments directly. Running turbo run build --dry=json locally and in a CI debug job, then diffing the hash inputs, almost always names the divergent variable immediately: a differing hash with identical source points at an environment input — an undeclared env var or a tool version — while differing source points at a real code difference or a path leak.

Diff the inputs Print each environment's hash and inputs, then compare. --dry=json hash + inputs compare CI vs local diff the sets declare the input hashes converge
Diffing the dry-run inputs names the divergent variable instead of guessing.

This turns a frustrating, opaque cache miss into a concrete diff of two input sets. Rather than adding env declarations by trial and error, you read exactly what each environment hashed and fix the one that differs. Making --summarize or --dry=json the first step when the cache does not share, rather than the last resort, is what keeps the cache trustworthy: every miss has a visible cause, and the fix is to declare the input that the two environments were treating differently so their hashes converge.

Declaring env and global inputs precisely

A durable fix is to make Turborepo's view of inputs match reality, so nothing that affects output is left out and nothing irrelevant is folded in. Declare per-task env for the variables a task reads, workspace-wide globalEnv for variables that affect everything, and globalDependencies for non-source files — a base tsconfig, a root config — that should invalidate everything when they change. The lockfile is folded in automatically because the resolved graph is an input to every task.

Declaring env and global inputs precisely A durable fix is to make Turborepo's view of inputs match reality, so nothing that affects output is left out and nothin Declaring env and global inputs precisely A durable fix is to make Turborepo's view of inputs match reality, so nothing that affects output is left out and nothing irrelevant is folded in.
Declaring env and global inputs precisely — the core idea of this section at a glance.

Listing a variable under a task's env means its value is part of the hash, so identical values hash the same across environments and different values correctly miss, instead of silently sharing a wrong artifact. Keeping this list accurate is what makes the cache both correct and portable: every real input is represented, so CI and local converge on the same key whenever they truly should, and diverge only when an input genuinely differs. The discipline is to add an input declaration whenever a task starts reading a new variable or file, so the hash always reflects the task's actual dependencies rather than an outdated approximation of them.

Pinning the toolchain so keys don't drift

A frequent cause of a cache that misses between CI and local is a toolchain version difference that leaks into the hash. If a build script invokes a tool whose version differs between a developer's machine and the CI runner, and that version affects the output or is captured in an input, the two environments hash differently and never share a result. Pinning the toolchain — the Node version via .nvmrc or Volta, the package manager via packageManager and Corepack, and any build tool via the lockfile — removes that variance so both environments run the identical tools.

Pin the toolchain Pin Node, package manager, and tools so keys don't drift. pin Node + pm identical tools declare pinning files explicit inputs keys converge real diffs only
Pinning the toolchain removes spurious version differences from the cache hash.

Declaring the pinning files as inputs makes the relationship explicit: listing .nvmrc in globalDependencies, for instance, means a Node version change correctly invalidates the cache, while pinning the version means the change only happens deliberately. The goal is a hash that reflects genuine differences — a real code or dependency change — and ignores spurious ones like an incidental tool-version mismatch between machines. When the toolchain is pinned and its pinning files are declared inputs, CI and local converge on the same key whenever their real inputs match, which is the precondition for the shared cache to actually deliver its speed-up across environments.

Frequently Asked Questions

Why does an env var cause a cache miss?

If a task reads an env var, its value is part of the output, so Turborepo folds declared env vars into the hash. A var set in one environment but not the other changes the hash and forces a miss.

Does the lockfile affect the cache key?

Yes — the resolved dependency graph is an input. A different lockfile (or an unpinned dependency) changes the hash, so keep it committed and use frozen installs on both sides.

Why does an environment variable cause a cache miss?

If a task reads an env var, its value can change the output, so Turborepo folds declared env vars into the hash. A var set in one environment but not the other changes the hash and forces a miss — declare it in env so both sides hash consistently.

How do I find which input is breaking the cache?

Run turbo run <task> --dry=json in both environments and diff the hashes and inputs. A differing hash with identical source points at an environment input — usually an undeclared env var or a tool version mismatch.

Can an undeclared env var cause a wrong cache hit?

Yes — if a task uses a var Turborepo does not know about, two environments with different values produce the same key and could replay each other's artifact. Declaring the var in env prevents both the wrong hit and the unexpected miss.

Why does my Turborepo cache miss between CI and local?

A hashed input differs — usually an undeclared env var, an unpinned Node or tool version, or an absolute path. Declare every input the task reads in env/inputs, pin toolchain versions, and diff the --dry=json hash inputs between environments to find the divergence.

Can an undeclared env var cause a wrong cache hit?

Yes — if a task reads a var Turborepo does not know about, two environments with different values produce the same key and could replay each other's artifact. Declaring the var in env prevents both the wrong hit and the unexpected miss.

How do I find which input is breaking cache sharing?

Run turbo run <task> --dry=json in both environments and diff the hashes and inputs. A differing hash with identical source points at an environment input; differing source points at a real code difference or a path leak.

Can a Node or tool version difference break cache sharing?

Yes — if a build script's behavior or a captured input depends on a tool version that differs between CI and local, the two hash differently and never share a result. Pin the toolchain (.nvmrc, packageManager, the lockfile) and declare the pinning files as inputs so both environments run identical tools.

Related

Remote Caching Setup