Skip to main content
Turborepo in a Real Monorepo: Field Notes on Cache Misses, NEXT_PUBLIC Poisoning, and the tasks Key That Replaced pipeline

Turborepo in a Real Monorepo: Field Notes on Cache Misses, NEXT_PUBLIC Poisoning, and the tasks Key That Replaced pipeline

August 23, 2026
Developer Tooling
10 min read

Turborepo is a task runner that hashes each script's inputs, caches its outputs, and replays them instead of re-running the work. The cache is only as correct as the inputs, outputs, and env you declare in turbo.json — every wrong answer we got out of it was a declaration nobody wrote.

Our team moved a two-app, six-package monorepo onto Turborepo 2 this year: a Next.js front end, a NestJS API, and shared UI, config, and type packages. The first week felt like a straight win, because CI stopped rebuilding packages nobody had touched. The second week we shipped a production build that was still pointing at the staging API, and the deploy log said cache hit, replaying logs. These are the notes we wish we had before that deploy.

Key takeaways

Turborepo is a memoization layer over package.json scripts. It does not install dependencies, link workspaces, or compile anything — pnpm and tsc still do that work.

Turborepo 2.0 renamed the top-level pipeline key in turbo.json to tasks. Run the official codemod, npx @turbo/codemod@latest migrate, instead of editing the file by hand.

An undeclared environment variable is the most dangerous cache input. Next.js inlines NEXT_PUBLIC values into the client bundle at build time, so a cache hit can physically replay another environment's URLs.

Turborepo 2.0 defaults to strict environment mode, where a task only sees variables listed in env or globalEnv. That default converts a silent wrong-value bug into a loud undefined one.

The --affected flag is only as good as your git history. A shallow CI clone has no base commit to diff against, so the filter quietly degrades into building everything.

What does Turborepo actually do, and what does it not replace?

Turborepo is a task runner for JavaScript monorepos that builds a dependency graph of your scripts, hashes the inputs of each task, and restores cached outputs when the hash matches a previous run. That is the entire product. It does not install packages, does not resolve workspace links, and does not bundle a single byte.

The division of labour decides where to debug. pnpm, npm, or yarn workspaces install dependencies and symlink internal packages; Turborepo reads that workspace definition rather than creating it. The compilers — tsc, tsup, the Next.js compiler, the Nest CLI — do the actual work, and Turborepo runs them as ordinary scripts. Turborepo itself only decides what runs, in what order, across how many cores, and whether it needs to run at all.

The useful mental model is memoization. Running turbo run build is a memoized call to pnpm build in every package, keyed on a hash of the source files, the dependency graph, the resolved lockfile entries, and the declared environment variables. Memoization is only correct when the function is deterministic with respect to its declared arguments. Every Turborepo bug our team has hit was an argument someone forgot to declare.

Why does turbo.json use tasks instead of pipeline now?

Turborepo 2.0 renamed the top-level pipeline key to tasks, because the old name implied a sequential pipeline when the value is really a set of task definitions whose order comes entirely from dependsOn. A configuration file still using pipeline fails on Turborepo 2 with a schema error, and the codemod renames it along with the other 2.x migrations.

Two pieces of the syntax carry most of the meaning. A dependsOn entry of caret-build means build every package this package depends on first, walking up the dependency graph. A dependsOn entry of plain build, without the caret, means run the build task of this same package first. Mixing those two up produces a graph that reads correctly and schedules wrong.

Setting persistent to true marks a task that never exits, such as a dev server. Turborepo 2 refuses to let any other task declare a dependency on a persistent task, which is the right error to raise: a task waiting on something that never finishes is a deadlock, not a build order.

Why is our build cache always missing?

A Turborepo task misses the cache when something inside its hash changed, and it caches nothing at all when its outputs array is empty. Those are two different failures with one symptom — a full rebuild every time — and turbo run build --dry=json tells them apart without running anything, because it prints each task's resolved inputs, environment variables, and final hash.

For a run that already happened, turbo run build --summarize writes a JSON file into the .turbo/runs directory containing the hash breakdown per task. Diffing two of those files across a miss is the fastest way we know to find the single input that moved.

A hash that never repeats usually means a generated artifact is being counted as source. Turborepo's default inputs are the git-tracked files in the package, so a committed dist folder or a build-stamped file makes every run unique; gitignore it.

A hash that never changes when it should means a real input is invisible. The file .env.local is the classic case: it is gitignored, so it is not in the default inputs, so editing it invalidates nothing.

The fix for the second case is the TURBO_DEFAULT sentinel in the inputs array, which means the default inputs plus whatever you list next. Listing only .env-star replaces the defaults entirely and silently stops tracking your source files, which is a far worse bug than the one you set out to fix.

Why did a cached build ship the wrong NEXT_PUBLIC value?

Next.js inlines every NEXT_PUBLIC variable into the client bundle at build time as a string literal, so the compiled .next output physically contains the values from whichever environment produced it. If those variables are not listed in the task's env array, Turborepo's hash cannot see them, the build looks identical to a previous one, and the cache restores a bundle pointing at the wrong API. Nothing throws. The build is green.

Turborepo 2.0 changed the default environment mode to strict, and that default is the real fix. In strict mode a task's process receives only the variables named in env and globalEnv, plus a small built-in allowlist. An undeclared variable is therefore undefined at build time rather than quietly present, which turns an invisible wrong-value bug into an obvious failure the team catches locally.

We now apply two distinctions mechanically. The env array is for variables that change the artifact, so they belong in the hash. The passThroughEnv array is for variables a task needs at runtime but that provably cannot change its output, such as an upload credential used after the bundle is written; those reach the process without joining the hash. The globalEnv array is for variables affecting every task in the repo, which in practice is a very short list.

Should internal packages export TypeScript source or compiled output?

Export raw TypeScript source from an internal package when every consumer is a bundler, and compile to JavaScript when any consumer is a Node process or an npm publish target. Turborepo's documentation calls these Just-in-Time Packages and Compiled Packages, and the choice changes how much there is to cache in the first place.

A Just-in-Time package has no build step at all: its exports field points straight at ./src/index.ts. The consuming Next.js app then needs the shared package listed in transpilePackages inside next.config.ts, so its compiler handles the untranspiled source. The upside is that there is no build task, so there is no cache entry to invalidate and no stale dist folder to debug. The downside is that every consuming app compiles the shared code again, and a NestJS service started with node dist/main.js cannot import a .ts file at all.

A Compiled package pays a build step — typically tsup or tsc emitting into dist with declaration files — to remove those limits. Our rule after living with both: UI components and type-only packages stay Just-in-Time, and anything imported by a Node runtime or published outside the repo gets compiled. Splitting on that line kept the build graph shallow, because most packages have no build task and caret-build has almost nothing to wait for.

How do we build only what changed in CI?

Running turbo run build --affected restricts a run to the packages touched since the base branch, and it is the flag Turborepo 2.1 added to replace the older --filter with a git range. It compares against main by default, and the TURBO_SCM_BASE and TURBO_SCM_HEAD variables override both ends of the comparison, which is what pull-request builds need.

The gotcha that cost our team an afternoon is that --affected is a git operation. The actions/checkout step in GitHub Actions defaults to a fetch-depth of 1, so the runner holds exactly one commit and has no base to diff against — and the run degrades into building everything with no error explaining why. Setting fetch-depth to 0 in the checkout step makes the flag behave.

Remote caching is what makes any of this matter on ephemeral runners. Without it, every CI job starts with an empty cache and the local hits developers enjoy on their laptops help nobody else. Running turbo login followed by turbo link wires up Vercel's remote cache, and setting TURBO_API, TURBO_TOKEN, and TURBO_TEAM points the same client at a self-hosted one.

For Docker, turbo prune with the --docker flag writes an out/json directory containing only the lockfile and the package.json files of that app's subgraph, plus an out/full directory with the source. Copying out/json and installing before copying out/full means the install layer only invalidates when a dependency actually changes, rather than on every source edit.

FAQ

Q: Does Turborepo replace pnpm workspaces?

A: No. Turborepo runs and caches tasks, while the package manager still installs dependencies and links internal packages. Turborepo reads the workspace definition from pnpm-workspace.yaml or the workspaces field in the root package.json.

Q: How is Turborepo different from Nx?

A: Both build a task graph and cache task outputs locally and remotely. Turborepo is configuration-only, wrapping the package.json scripts you already have in one turbo.json, while Nx adds plugins, generators, executors, and inferred targets. If your scripts already work, Turborepo is the smaller migration.

Q: Can a Turborepo cache hit be wrong?

A: Yes. A cache hit is exactly as honest as the declared inputs and env. If a file or variable changes the output but is not part of the hash, Turborepo will confidently replay a stale artifact, so verify a suspicious hit with turbo run build --dry=json.

Q: Why does a dev task need persistent set to true?

A: A dev server never exits, and persistent true tells Turborepo not to wait for it. Turborepo 2 also rejects any task that declares dependsOn on a persistent task, because that dependency could never be satisfied.

Q: Is remote caching worth it for a small team?

A: For local work, no — the local cache under node_modules/.cache/turbo already covers it. For CI, yes, because CI containers are ephemeral and start with an empty cache on every run, which is exactly where a full rebuild costs the most.