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
# 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.
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:
// 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
# 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/globalEnvso 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
--summarizein CI to diff hash inputs when a cache miss is unexpected.
- Declare all env inputs in
env/globalEnvso 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
--summarizein 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.
# 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.
// 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.
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.
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.
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 — the shared remote cache configuration this fix builds on.