Turborepo · intermediate

Turborepo cache differs in CI: compare declared environment inputs

When Turborepo hits locally but misses in CI (or vice versa), the root cause is almost always a difference in declared environment inputs: `env`, `globalEnv`, `globalDependencies`, `dotenv`, or pipeline-level `dependsOn`/`inputs` that diverge between the two environments. The fix is to audit the hash inputs on both sides, align them to the smallest stable set, and then prove parity with a reproducible cache hit.

The symptoms

  • Turborepo reports a cache miss in CI for a task that was a cache hit locally, with the same source commit and lockfiles.
  • `turbo run <task> --summarize` or `--dry-run=json` shows a different hash on CI versus a developer laptop.
  • CI logs include messages such as "cache miss, executing <task>" or "cache bypass" for tasks that should be restored.
  • Remote cache lookups return `MISS` while local `.turbo/cache` artifacts still contain a previous successful output.
  • Flaky cache behavior tracks environment changes: different Node version, missing `.env`, or a CI-only secret becoming part of the hash.
  • Cache hit locally, miss in CI, but hit again in CI on a second run with no code changes (hash inputs are non-deterministic).

Likely causes

  • CI runner injects environment variables that are not declared in the pipeline's `env` whitelist (for example `CI`, `GITHUB_ACTIONS`, `RUNNER_OS`, dynamic `BUILD_ID`).
  • The pipeline declares `globalEnv` or `env` too narrowly, omitting a variable that exists locally, so the hash inputs differ between environments.
  • `.env` files referenced via `dotenv` exist locally but are absent or different in CI, changing the hashed content.
  • `globalDependencies` includes lockfiles (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`) whose content genuinely differs because of a registry, network, or protocol change.
  • The remote cache provider is configured for one environment but not the other, so CI silently falls back to local-only cache or no cache.
  • Pipeline-level `dependsOn` order or missing `inputs` declarations cause CI to evaluate the hash against a different file set than the local run.

First ten minutes

  1. 01Reproduce on the same commit SHA in both environments and capture `turbo run <task> --dry-run=json` output from each side.
  2. 02Diff the two JSON outputs: focus on the `hash` and `inputs` fields for the affected task.
  3. 03Run `turbo run <task> --summarize` locally to write `.turbo/cache/<task>-<hash>.json` and inspect the recorded inputs.
  4. 04Capture the exact `env` of each runner via a one-line pre-step that prints the sorted, filtered environment, and diff the two snapshots.
  5. 05Verify the remote cache is reachable from CI by checking `TURBO_TOKEN`, `TURBO_API`, and provider-side read access for the affected task hash.
  6. 06Confirm `globalDependencies` entries (lockfiles, root configs) are byte-identical between local and CI worktrees before deeper diagnosis.

Evidence to collect

  • Task hash strings from `--dry-run=json` on both local and CI runners for the same commit.
  • Per-task `inputs` arrays (file globs expanded, env keys/values) from the dry-run JSON on each side.
  • Sorted, allowlisted environment variable lists captured identically on local and CI (no secrets, just keys and lengths).
  • `turbo run --summarize` output and the contents of `.turbo/cache/<task>-<hash>.json` for a known good run.
  • Remote cache provider audit log entries (or equivalent) showing `HIT`/`MISS` for the affected hash.
  • Resolved `turbo.json` after `extends` resolution, to confirm the pipeline actually applied in CI matches local.

Where to look

  • The boundary between local developer environment and CI runner, specifically which environment variables each side exports and which `dotenv` files each side loads.
  • The Turborepo configuration boundary: `turbo.json` at repo root, any extended config files via `extends`, and per-package `turbo.json` overrides.
  • The remote cache boundary: `TURBO_API`, `TURBO_TOKEN`, `TURBO_TEAM`, and provider-side scopes/tokens configured for CI.
  • The lockfile and globalDependencies boundary: `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, and any `.npmrc`/`.pnpmrc` that affect resolved contents.
  • The pipeline task graph boundary: `dependsOn`, `inputs`, `outputs`, and `cache` settings for the affected task and its dependencies.
  • The CI runner boundary: GitHub Actions, GitLab CI, CircleCI, Buildkite, or other provider-specific environment injection and workspace setup.

Diagnostic steps

  1. 01Run `turbo run <task> --dry-run=json` locally and in CI on the same commit; if the `hash` field differs, the inputs differ.
  2. 02Diff the dry-run JSON `inputs` arrays file-by-file and key-by-key to identify the first divergence between local and CI.
  3. 03Inspect the resolved `turbo.json` by running `turbo run <task> --dry-run --graph` and confirm CI uses the same pipeline definition as local (account for `extends`).
  4. 04Print a sorted, allowlisted environment snapshot on each side (keys and value lengths only) and diff to surface undeclared variables leaking into the hash.
  5. 05Compare `globalDependencies` files byte-for-byte between the two checkouts; non-deterministic lockfile regeneration is a common cause of hash drift.
  6. 06Confirm the CI runner can reach the remote cache: valid `TURBO_TOKEN`, correct `TURBO_API`, and provider-side permission for the workspace/team.
  7. 07If remote cache is reachable but returns `MISS`, verify that a prior successful CI run on the same commit actually uploaded artifacts (provider audit log shows a write).
  8. 08Reproduce locally with `--cache=remote:<api> --token=<token>` to isolate whether the divergence is hash-based or scope/permission-based.

Common mistakes

  • Adding the entire process environment to `globalEnv` instead of enumerating the variables that legitimately affect task output, which then tracks irrelevant CI-specific values like `BUILD_ID`.
  • Assuming `.env` files are present in CI; many providers only inject a subset of variables, and `dotenv` will fail silently or produce empty values that change the hash.
  • Assuming lockfiles are stable across machines; protocol changes, registry mirrors, or postinstall scripts can mutate lockfile content between local and CI.
  • Reading cache hit/miss from build output without checking whether the remote cache was actually consulted; local-only fallback hides a real config mismatch.
  • Conflating "different hash" with "different cache"; a hash mismatch guarantees a miss, but a miss with the same hash indicates a remote cache scope, auth, or upload problem.
  • Mutating `globalDependencies` or `env` to force a hit without verifying that the new inputs still represent the true build inputs, producing false cache hits.

Safe fixes

  • If a CI-only variable appears in the hash, add it to the task's `env` whitelist only when the variable genuinely changes output; otherwise leave it undeclared so it does not affect the hash.
  • If a locally-set variable is missing in CI, align CI to the local value (for example, pin the same `NODE_ENV` or feature flag) rather than widening the hash inputs.
  • If `.env` content differs, commit a checked-in example file (for example `.env.example`) and have CI generate the real `.env` from secrets before `turbo` runs, then declare the relevant keys in `env`.
  • If lockfiles drift, regenerate them deterministically in CI from a clean checkout and verify byte equality against a known-good artifact before re-running.
  • If the remote cache is misconfigured, set `TURBO_API`, `TURBO_TOKEN`, and `TURBO_TEAM` explicitly in the CI environment, scoped to read/write for the affected workspace, and re-run.
  • If the hash inputs are correct but misses persist, narrow the change to a single declared input at a time and re-run `--dry-run=json` on both sides after each change.

Prove the fix

  1. 01On the same commit, run the affected task locally and in CI with `--dry-run=json`; the per-task `hash` value must be identical on both sides.
  2. 02A first CI run uploads artifacts (provider audit log shows a write for the hash); a second CI run on the same commit restores them and logs a cache hit instead of executing the task.
  3. 03Local dry-run with `--cache=remote:<api> --token=<token>` returns the same hash and reports a remote hit after the CI upload.
  4. 04Changing a declared input (env value, lockfile, or source file) produces a new, distinct hash on both sides; reverting that input restores the previous hash on both sides.
  5. 05The CI job's duration for the affected task drops to the cached-restore time, and no `cache miss, executing <task>` line appears in logs for unchanged inputs.

Prevention and next steps

  • Treat `turbo.json` as a contract: keep `env`, `globalEnv`, `globalDependencies`, and `dotenv` minimal, explicit, and reviewed in pull requests.
  • Pin toolchain versions (Node, package manager) in CI to match the developer baseline, and surface any mismatch in a pre-flight check.
  • Generate `.env` in CI from a committed template plus provider-managed secrets, so the same keys exist on both sides with stable values.
  • Run a periodic dry-run comparison job on a known commit to detect hash drift early, before it surfaces as a cache miss in a release pipeline.
  • Document which secrets are allowed to flow into the hash (only those that change output) and audit CI variable injection against that list.

Safe commands and checks

turbo run <task> --dry-run=json | jq '.tasks[] | select(.task == "<task>") | {hash, inputs}'
turbo run <task> --summarize
turbo run <task> --dry-run --graph
turbo run <task> --cache=remote:<api> --token=<token> --team=<team>
diff <(jq -S '.tasks' local-dry-run.json) <(jq -S '.tasks' ci-dry-run.json)
cat .turbo/cache/<task>-<hash>.json | jq '.inputs'
node -e "const fs=require('fs');const e=Object.fromEntries(Object.entries(process.env).filter(([k])=>['NODE_ENV','CI','TURBO_TOKEN','GITHUB_ACTIONS','RUNNER_OS'].includes(k)));console.log(JSON.stringify(Object.fromEntries(Object.entries(e).map(([k,v])=>[k,String(v).length])),null,2))"