A cache hit that should have been a miss is worse than a slow build. The task finishes in milliseconds, CI goes green, and the artifact you deploy still carries the staging URL that was baked in three builds ago. Environment variables sit behind most Turborepo cache miss reports, and behind the more dangerous inverse: false hits that replay stale outputs.
This guide reproduces every failure mode on a real pnpm monorepo and fixes each one: a poisoned cache from an undeclared variable, strict mode filtering a variable out of the build, .env edits the hash never sees, a globalEnv entry that silently hashes nothing, and a task that invalidates its own cache on every run. Each section leads with the symptom, shows the real turbo output, and ends with the turbo.json change that fixes it. The repo is the backend monorepo from our Turborepo and pnpm setup guide, running on a Node.js 24 lab VM. Every hash and timing below comes from runs on turbo 2.10.5 with pnpm 11 on Ubuntu 24.04 in July 2026.
How Turborepo Decides Between a Hit and a Miss
Turborepo hashes each task before running it. The hash covers the resolved task definition from turbo.json, the package’s source files (the inputs key), the lockfile, and the values of every environment variable declared in env and globalEnv. Same hash means cache hit and the saved outputs are restored; different hash means the task runs. A variable that is not declared is simply not part of the hash, and that single fact explains most of the failures in this article. The environment variables documentation covers the declaration keys; what follows is how they fail in practice.
You can see exactly what turbo hashed for a task without running it:
API_URL=https://api.example.com turbo run build --filter=api --dry=json
The environmentVariables section of the JSON shows which variables were declared, which were picked up, and the SHA-256 of each value (turbo hashes values rather than storing them, so secrets never land in the dry-run output):
{
"taskId": "api#build",
"hash": "2139cc3561c98986",
"envMode": "strict",
"environmentVariables": {
"specified": {
"env": ["API_URL"],
"passThroughEnv": null
},
"configured": [
"API_URL=137b9e5e4e13211ce3487cb1f3148ad0ef4147e2a4647ca12191bd2c8528b646"
],
"inferred": [],
"passthrough": null
}
}
Keep this command in reach. Almost every problem below is diagnosed by comparing that block between two runs.
The Poisoned Cache: When a Hit Is the Bug
This is the failure that ships broken artifacts to production. Our api app bakes its API endpoint into the build output, a pattern you will recognize from any app that generates a config file or embeds public variables at compile time. The build script writes the current value of API_URL into dist/config.json:
vim apps/api/scripts/write-config.mjs
The script reads the variable at build time and persists it into the output directory:
import { mkdirSync, writeFileSync } from "node:fs";
mkdirSync("dist", { recursive: true });
writeFileSync(
"dist/config.json",
JSON.stringify({ apiUrl: process.env.API_URL ?? null }, null, 2)
);
console.log("wrote dist/config.json with API_URL =", process.env.API_URL ?? "(unset)");
Now build with the staging endpoint, then build again with the production endpoint. API_URL is not declared anywhere in turbo.json, and the runs use loose mode so the variable reaches the script:
API_URL=https://staging.api.internal turbo run build --filter=api --force --env-mode=loose
API_URL=https://api.example.com turbo run build --filter=api --env-mode=loose
The second run comes back in 13 milliseconds as a full cache hit, and the artifact still contains the staging URL:

Because API_URL never entered the hash, both runs computed the same hash (aee16549d83678f2) and the second run replayed the first run’s outputs, replayed log line included. Deploy that artifact and production talks to staging. If your team shares a remote cache, the poisoned artifact does not stay on one machine: every developer and every CI runner with the same hash restores it.
The fix is one line. Declare the variable on the task that consumes it:
vim turbo.json
Add the env key to the build task:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.md"],
"outputs": ["dist/**"],
"env": ["API_URL"]
}
}
}
With the declaration in place, each value of the variable produces its own hash. Staging and production builds stop sharing artifacts, and repeating a build with the same value still hits the cache:

The staging value hashed to 11df62f18d37324d and the production value to 15992bfd70b0bfec. Two hashes, two cached artifacts, each replayed only for its own value. This is the whole model in one screenshot: declared variables partition the cache, undeclared variables poison it.
The Build Cannot See a Variable That Is Definitely Set
The inverse symptom looks like a broken shell. You export a variable, the build runs, and the variable is undefined inside the task. Strict mode is doing that on purpose. It is the default in the 2.x line, and it filters the task’s runtime environment down to declared variables plus a small built-in allowlist (HOME, PATH, PWD, SHELL and a few others). Here is the same build with API_URL exported but not declared, under default strict mode:
API_URL=https://api.example.com turbo run build --filter=api --force
The script sees nothing, and the artifact records a null endpoint:
api:build: cache bypass, force executing 4256733d40406a43
api:build: wrote dist/config.json with API_URL = (unset)
Note the hash: 4256733d40406a43 is byte-for-byte the hash we got with no variable set at all. Strict mode is consistent that way. An undeclared variable neither reaches the task nor affects its hash, which turns a forgotten declaration into a loud, reproducible failure instead of a silent poisoned cache. That trade is why strict mode became the default.
Which key you declare the variable in depends on what the variable does. If it changes the output, it belongs in env so the hash tracks it. If the task only needs it at runtime and the output does not depend on it, put it in passThroughEnv: the variable becomes visible to the task but stays out of the hash. Deploy tokens and cloud credentials are the classic case, since rotating a secret should not invalidate every cached build:
{
"tasks": {
"deploy": {
"dependsOn": ["build"],
"passThroughEnv": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
}
}
}
Frameworks get special treatment. Turborepo infers public variable prefixes per package, so a Next.js app automatically includes NEXT_PUBLIC_* in its hash and a Vite app includes VITE_*, without any declaration. Our lab apps are plain Node services (the dry-run above shows "inferred": []), so nothing was inferred, and you can disable the behavior everywhere with --framework-inference=false if you want declarations to be the single source of truth.
Declaring One Variable Rebuilt Every Package
There is a scoping surprise in the fix above. The env key we added sits on the build task in the root turbo.json, and that definition applies to the build task of every package in the workspace. Change API_URL and the shared logger, db and worker packages all miss their caches too, even though none of them read the variable.
Scope the declaration to the one package that consumes it with a package-level turbo.json:
vim apps/api/turbo.json
Extend the root config and override only the build task:
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
Remove the env key from the root task, prime the cache, then change the value. Only api rebuilds:
@repo/db:build: cache hit, replaying logs aee1c43112327739
@repo/logger:build: cache hit, replaying logs b99b37505a432ff8
api:build: cache miss, executing 115f0228b2ce7886
worker:build: cache hit, replaying logs 6b3aa7a19e918749
Cached: 3 cached, 4 total
In a workspace with dozens of packages this is the difference between one task rebuilding and the whole graph rebuilding every time an app-specific variable moves.
Editing .env Does Nothing to the Cache
Two separate behaviors trip people here, and they compound. First, Turborepo does not load .env files into a task’s runtime at all; that is your framework’s or dotenv’s job. Second, the default input hash only covers git-tracked files, and .env is almost always gitignored. So you edit apps/api/.env, rebuild, and get a full-speed cache hit with the old values:
api:build: cache hit, replaying logs 7ac037ada3794cd3
Time: 10ms >>> FULL TURBO
Declare the file as an input so its contents join the hash. $TURBO_DEFAULT$ keeps the normal input set and the extra glob adds the env files on top, tracked or not:
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "!**/*.md", ".env*"],
"outputs": ["dist/**"]
}
}
}
After the change, editing the file produced a fresh hash and a rebuild on our lab (3e942a200bbc7892 to 60683d0ff1668fe2). One warning before you copy that glob into a shared repo: with a shared remote cache, hashing .env contents means two developers with different local .env files will never share artifacts for that task. That may be exactly right or wildly wasteful depending on what lives in the file.
globalEnv Is Declared but the Cache Still Hits
This one generates bug reports upstream because it looks impossible. A team declares a variable in globalEnv, builds on macOS, and CI on Linux restores the macOS artifact anyway. A GitHub issue reported exactly that with OSTYPE, and we reproduced the cause in one minute. Watch closely:
echo $OSTYPE
env | grep -c OSTYPE
The first command prints a value, the second finds nothing in the environment:
linux-gnu
0
OSTYPE is a shell variable that bash sets for its own use and never exports. Child processes, turbo included, do not inherit it. Declared in globalEnv, it hashes as unset on macOS and as unset on Linux, so the hashes match and the cross-platform artifact gets reused. Our lab confirmed it: two runs with globalEnv: ["OSTYPE"] produced the identical hash 1d6bf65548f7e12d, and only an explicit export OSTYPE changed it to b7c644b3db844679. Any variable set in a script without export, or assigned only for the current shell, behaves the same way. When a declared variable seems ignored, check env output before blaming turbo; our shell environment guide covers the shell-variable versus environment-variable distinction in depth.
The Cache Misses on Every Run With Nothing Changed
The opposite disease: no edits, yet every build is a miss. The most common cause is a task that writes into its own inputs. Code generation is the classic pattern, so we made write-config.mjs also stamp a build timestamp into src/generated/build-info.json, a file committed to the repo the way generated clients and schema types often are. Three consecutive builds, zero source edits:
api:build: cache miss, executing 9414716ff127fe97
api:build: cache miss, executing a5535c5dec3dd84d
api:build: cache miss, executing f8238b9a071ec477
Each run rewrites the generated file, the next run hashes the new contents as an input, and the cache can never converge. The fix is to carve generated paths out of the input set with a negation:
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "!**/*.md", "!src/generated/**"],
"outputs": ["dist/**"]
}
}
}
On our lab the very next build came back as a hit, and stayed a hit across repeated runs. Framework build directories cause the same loop from the outputs side: Next.js standalone builds are the best documented case, where the .next directory turbo restores gets modified by the following build. The pattern in the upstream discussion is the same medicine applied to outputs: cache .next/** but negate .next/cache/** and the trace metadata files.
Removing a Variable Does Not Trigger a Rebuild
Older Turborepo releases had a real asymmetry bug here: adding a declared variable missed the cache, removing it hit the wrong artifact. Worth retesting before carrying the folklore forward, so we did. Back on the root-level env declaration from the poisoned-cache section, run without the variable, with it, then without it again:
api:build: cache miss, executing 7ac037ada3794cd3
api:build: cache hit, replaying logs 11df62f18d37324d
api:build: cache hit, replaying logs 7ac037ada3794cd3
The third run correctly returns to the first run’s hash and restores the unset-variable artifact, "apiUrl": null and all. The current 2.x line treats unset as a hashable state of its own. If you still see stale behavior on this pattern, you are on an old binary and the fix is an upgrade, not a config change.
A Turborepo Cache Miss Debugging Workflow
When a hash surprises you, resist the urge to sprinkle --force and move on. This sequence found every problem in this article, in this order.
Compare what the hash saw between a good run and a bad run:
turbo run build --filter=api --dry=json | python3 -c "
import json, sys
task = [t for t in json.load(sys.stdin)['tasks'] if t['taskId'] == 'api#build'][0]
print(json.dumps({k: task[k] for k in ('hash', 'envMode', 'environmentVariables')}, indent=2))"
A variable missing from configured is either undeclared or not exported. A variable present with a different SHA-256 between runs is your changed value. If the environment block matches and the hash still differs, the change is in file inputs, so capture full run summaries and diff them:
turbo run build --filter=api --summarize
Each summary lands as JSON under .turbo/runs/ with the resolved inputs, their hashes, and the expanded environment sections. Two summaries and a diff tool will point at the exact file or variable that moved. While investigating, --force bypasses cache reads without touching your config, and --env-mode=loose is a fine ten-second test to confirm a strict-mode filtering theory. Just never promote either flag into a package script or CI pipeline as the “fix”: loose mode in CI is how the poisoned cache from the first section reaches production, and the Docker and CI caching guide shows what those pipelines should look like instead.
Audit Your turbo.json Before You Trust the Cache
Every failure above traces back to a gap between what a build reads and what the hash tracks. Ten minutes with this list closes the gaps:
- Every variable read at build time is declared in
env, scoped to the package that reads it via a package-levelturbo.jsonwhen only one app cares. - Secrets and deploy credentials sit in
passThroughEnv, notenv, so rotation does not invalidate builds. - Tasks that read
.envfiles declare them ininputs, with eyes open about what that does to remote cache sharing. - Declared variables are actually exported;
env | grep NAMEproves it,echo $NAMEproves nothing. - Generated files are negated from
inputsor written only to output directories, and framework cache directories are negated fromoutputs. - No
--env-mode=loosein package scripts or CI. If a build only works in loose mode, a declaration is missing, and strict mode just told you which artifact you were about to poison.
The cache is only as honest as the hash, and the hash only knows what you declare. Declare everything the build consumes and Turborepo’s aggressive caching becomes safe to lean on, from a laptop to the full CI fleet.