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.
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=...
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.
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.
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.
Related
- Remote Caching Setup — the shared remote cache configuration this fix builds on.