Turborepo CI Cache Hit Rate Up to 90%: Essential `dependsOn`, `outputs`, and `env` Configuration
After adopting Turborepo in a monorepo, I've been there too — that moment of confusion when you wonder, "Why isn't the cache hitting?" You haven't changed anything, yet the build runs from scratch again, and CI still takes over 10 minutes. In most cases, the culprit turns out to be a single setting in turbo.json.
For those new to Turborepo, here's a one-line summary: Turborepo is a tool that runs tasks like build, test, and lint across multiple packages in a monorepo in parallel, and restores unchanged parts directly from cache. The cache is the key — and for it to work properly, three fields in turbo.json must be configured correctly.
Declare execution order and parallelism precisely with dependsOn, specify what outputs to store with outputs, and isolate environment variables to include in the cache hash with env — these three things alone can push your CI cache hit rate up to 90%. The Mercari engineering team used this approach to cut Turbo task execution time by 50% and total CI job duration by 30%. This article walks through how each field affects cache hash calculation from first principles, along with configuration examples you can use in production right away.
Core Concepts
How Turborepo Caching Works
Before running a task, Turborepo first computes a hash (fingerprint) of its inputs. If that hash matches a previously cached result, it restores the output without actually running the task. If the hash differs, it runs the task fresh and stores the output in cache.
A cache hash is a unique identifier computed from a combination of source files, dependencies, environment variables, configuration files, and more. The guarantee that the same hash equals the same output is what makes cache trustworthy.
What determines cache hit rate ultimately comes down to "what you include in the hash." Include too much, and even a minor change causes a miss. Include too little, and you end up using cached results even when something has genuinely changed.
By default, Turborepo uses every git-tracked file within a package as hash inputs (inputs). You can use the inputs field to narrow this scope. If frequently changing files like .env files or *.log files are included in the default inputs, cache misses will keep occurring even without any code changes. Since files in .gitignore are automatically excluded, most inputs-related problems can actually be resolved simply by managing git-tracked files properly.
dependsOn — Controls Both Execution Order and Parallelism
dependsOn declares which tasks must complete before the current task runs. Here, a single ^ prefix completely changes the meaning.
{
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {
"dependsOn": []
}
}
}| Declaration | Meaning | Example Use Case |
|---|---|---|
"^build" |
build of dependency packages must complete first |
Guarantees shared library → app ordering |
"build" |
build within the current package must complete first |
Controls task order within the same package |
[] (empty array) |
No dependencies, can run in parallel immediately | Independent tasks like lint, format |
Setting dependsOn: [] on lint means it runs in parallel immediately, regardless of other tasks. Conversely, adding unnecessary dependencies reduces parallelism. Accurate declarations lead directly to faster pipelines.
outputs — Without This, Caching Means Nothing
Honestly, at first I vaguely assumed "using Turborepo means things get cached automatically" — but there's an important trap here.
There's a subtle difference between not declaring outputs at all and explicitly setting outputs: [] (an empty array). In both cases, the task completion state itself is recorded in cache. So you might see >>> FULL TURBO on the next run — but the actual output files, like the dist/ folder, won't be restored. This is where the confusion comes from: "It's a cache hit, so why are my build artifacts missing?" You must explicitly list paths in outputs for the cache to restore them.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": ["*.tsbuildinfo"]
}
}
}The ! prefix is an exclusion pattern. There's a reason for excluding .next/cache/** — Next.js's internal build cache changes frequently with each build, and including it in outputs causes Turborepo's cache to be unnecessarily invalidated. Including TypeScript's .tsbuildinfo in outputs preserves the incremental build cache as well, which also speeds up type checking.
In glob patterns,
**matches all subdirectories, while*only matches files in the current directory.dist/**captures all files underdistas cache targets.
env — Reflects Environment Variable Changes in the Cache Hash
If the value of an environment variable declared in env changes, a cache miss occurs. Conversely, for variables not declared, Turborepo won't detect value changes, potentially causing it to use stale cached results.
It wasn't immediately intuitive why passThroughEnv is needed — but thinking about it this way makes it clear. CI=true is a variable set only in CI environments. Including it in the cache hash means the local development and CI environments will always have different hashes. A variable that has no effect on the actual build output would prevent cache sharing. By separating it into passThroughEnv, the variable is passed through at runtime but excluded from the hash, preventing unnecessary cache misses.
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_ANALYTICS_ID"],
"passThroughEnv": ["CI", "GITHUB_TOKEN"]
},
"test": {
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI"]
}
}
}Starting with Turborepo 2.0, the default envMode changed to strict. Undeclared environment variables are not exposed to tasks at all. Variables automatically injected by CI like GITHUB_TOKEN and CI must be explicitly listed in passThroughEnv, or builds may fail — this requires careful attention.
It's recommended to keep only truly global variables like
NODE_ENVinglobalEnv. For everything else, isolating them to per-taskenvminimizes the scope of cache invalidation.
Practical Application
Example 1: Full Pipeline for a Next.js + Shared UI Library Monorepo
This is the most common configuration you'll encounter in practice. apps/web depends on packages/ui, and this dependency relationship must be clearly declared with dependsOn: ["^build"] to guarantee ordering.
// turbo.json (root)
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"],
"env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_ANALYTICS_ID"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI"]
},
"lint": {
"dependsOn": [],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}persistent: true on the dev task means more than just "this runs for a long time." Without this flag, Turborepo can forcefully terminate the dev process after all other tasks complete. This is sometimes why your dev server shuts down unexpectedly, so it's recommended to always pair it with cache: false.
dependsOn: ["^build"] on test is appropriate for integration tests that rely on build artifacts. For unit tests that don't need build output, setting dependsOn: [] and running them in parallel is faster.
Example 2: Isolating Environment Variables with Per-Package turbo.json
When a monorepo contains multiple Next.js apps, each app requires different environment variables. Putting all of them in the root turbo.json means that when one app's environment variable changes, the cache for other apps gets invalidated too.
Turborepo's Package Configurations feature solves this problem.
// packages/web/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_FEATURE_FLAG_X", "NEXT_PUBLIC_WEB_API_URL"]
}
}
}// packages/admin/turbo.json
{
"extends": ["//"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_ADMIN_API_URL", "NEXT_PUBLIC_ADMIN_KEY"]
}
}
}"extends": ["//"] means inheriting configuration from the root turbo.json. Isolating environment variable scope to the package level also reduces the scope of cache invalidation proportionally. When the web app's environment variables change, the admin app's cache remains intact.
Example 3: Avoiding Over-Declaration in globalEnv
Early on, I threw every variable into globalEnv with a "just put it all in there" mindset — and then experienced the entire cache getting wiped every time a single variable changed in CI.
// Problematic configuration — a single variable change invalidates the entire cache
{
"globalEnv": ["API_URL", "DB_URL", "AUTH_SECRET", "CI", "VERCEL_URL", "GITHUB_TOKEN"]
}// Improved configuration — impact scope limited to the task level
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL"],
"passThroughEnv": ["VERCEL_URL"]
},
"test": {
"env": ["DATABASE_URL"],
"passThroughEnv": ["CI", "GITHUB_TOKEN"]
}
}
}Looking at the case published by the Mercari engineering team when they introduced Remote Cache, simply organizing environment variable scope at the task level and specifying outputs precisely cut Turbo task execution time by 50% and total CI job duration by 30%. Placing the cache server in the same region as CI was also effective, but Remote Cache won't work properly if the configuration itself isn't solid.
Pros and Cons
Benefits
| Item | Effect |
|---|---|
| Remote Cache activation | CI time for unchanged code can be cut up to 10x |
Precise outputs configuration |
Guarantees cache validity, prevents stale artifact bugs |
Per-task env isolation |
Unrelated environment variable changes don't affect cache |
Using dependsOn: [] |
Eliminates unnecessary serial execution, maximizes parallelism |
| Package Configurations | Fine-grained control over cache scope per package |
| Local-CI cache sharing | Cache can be reused between local and CI when using passThroughEnv |
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
Full cache invalidation on turbo.json changes |
All caches reset when the config file changes | Design carefully upfront, minimize changes |
strict envMode blocking undeclared variables |
CI-injected variables may not be passed to tasks | Explicitly allow with passThroughEnv |
| Volatile files included in inputs | Files like .env, *.log cause persistent cache misses |
Align with .gitignore patterns |
| Remote Cache cost | Vercel free plan has capacity limits; self-hosting incurs operational costs | Choose a plan suited to team size, consider ducktors/turborepo-remote-cache |
| Remote Cache network latency | Cache server far away can actually make things slower | Place cache server in the same region as CI |
| Configuration gaps in team collaboration | Multiple developers managing passThroughEnv independently can lead to missing variables |
Designate turbo.json as a required PR review file |
A stale artifact is when the source has already changed but a previous build output is still being used. This can happen when
outputsis not specified accurately, causing incorrect results upon cache restoration.
Most Common Mistakes in Practice
-
Not declaring
outputsat all — If you see>>> FULL TURBObut thedist/folder is empty, this is likely the cause. Even if the task succeeds, the output isn't stored in cache and therefore can't be restored. When writingturbo.jsonfor the first time, always verifyoutputsfor each task. -
Putting all environment variables into
globalEnv— A single variable change invalidates the cache for the entire monorepo. Keep only truly global things likeNODE_ENVthere, and isolate the rest into per-taskenv. -
Omitting
cache: falseandpersistent: trueon thedevtask — The dev server maintains continuous state, and having caching enabled can cause unexpected behavior. Withoutpersistent: true, Turborepo may terminate the process prematurely, so these two options should always be set together.
Conclusion
Declaring execution order and parallelism precisely with dependsOn, specifying what outputs to store with outputs, and isolating environment variables to include in the cache hash with env — these three things are the core of making Turborepo caching work correctly. When the conditions are met, you can push your CI cache hit rate up to 90%.
Here's a checklist you can use to review your turbo.json right now:
- Is
outputsdeclared for each task? - Are there any variables in
globalEnvbesidesNODE_ENV? → Consider moving them to per-taskenv - Are CI-injected variables like
CIandGITHUB_TOKENinpassThroughEnv? - Does the
devtask have bothcache: falseandpersistent: true? - Do unit test tasks have unnecessary
dependsOn: ["^build"]attached?
If you want to verify actual improvements after reviewing, here are 3 steps you can start right now:
-
Distribute environment variables declared in
globalEnvinto per-taskenvandpassThroughEnv— This is the fastest way to improve cache hit rate. Try moving variables likeCIandGITHUB_TOKENtopassThroughEnvfirst, and you'll feel the effect immediately. -
Inspect your pipeline with
turbo run build --dry-run— This shows which tasks are planned in which order without actually running anything. It's worth checking whether dependencies are as you expect first. -
Verify cache hit status with
turbo run build --summarize— After running, a report is output showing each task's cache hit/miss status and reason. If any tasks are missingoutputs, you'll see it here immediately.
References
- Configuring tasks | Turborepo
- turbo.json Configuration Reference | Turborepo
- Caching | Turborepo
- Using environment variables | Turborepo
- Remote Caching | Turborepo
- Turborepo 2.0 Release Notes
- Turborepo 2.9 Release Notes
- Accelerating CI with Turborepo Remote Cache | Mercari Engineering
- ducktors/turborepo-remote-cache | GitHub