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.

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=...

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.

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.

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