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
- 01Reproduce on the same commit SHA in both environments and capture `turbo run <task> --dry-run=json` output from each side.
- 02Diff the two JSON outputs: focus on the `hash` and `inputs` fields for the affected task.
- 03Run `turbo run <task> --summarize` locally to write `.turbo/cache/<task>-<hash>.json` and inspect the recorded inputs.
- 04Capture the exact `env` of each runner via a one-line pre-step that prints the sorted, filtered environment, and diff the two snapshots.
- 05Verify the remote cache is reachable from CI by checking `TURBO_TOKEN`, `TURBO_API`, and provider-side read access for the affected task hash.
- 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
- 01Run `turbo run <task> --dry-run=json` locally and in CI on the same commit; if the `hash` field differs, the inputs differ.
- 02Diff the dry-run JSON `inputs` arrays file-by-file and key-by-key to identify the first divergence between local and CI.
- 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`).
- 04Print a sorted, allowlisted environment snapshot on each side (keys and value lengths only) and diff to surface undeclared variables leaking into the hash.
- 05Compare `globalDependencies` files byte-for-byte between the two checkouts; non-deterministic lockfile regeneration is a common cause of hash drift.
- 06Confirm the CI runner can reach the remote cache: valid `TURBO_TOKEN`, correct `TURBO_API`, and provider-side permission for the workspace/team.
- 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).
- 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
- 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.
- 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.
- 03Local dry-run with `--cache=remote:<api> --token=<token>` returns the same hash and reports a remote hit after the CI upload.
- 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.
- 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))"