Turborepo Pipeline Configuration
A Turborepo pipeline is a declarative task graph: you describe how each task depends on others, what it consumes, and what it produces, and Turborepo computes a topological execution order, a content hash per task, and a caching boundary for free. Get the declaration right and incremental builds skip everything that has not changed; get it wrong and you face perpetual cache misses, MODULE_NOT_FOUND errors from out-of-order builds, or leaked secrets. This page covers the full turbo.json schema, the dependsOn graph semantics, deterministic inputs/outputs hashing, environment-variable scoping, and CI integration.
The pipeline definition is the contract every other piece of your Monorepo Architecture & Orchestration setup depends on. It is also what feeds the cache: the inputs and outputs you declare here are exactly what your Remote Caching Setup stores and keys on, so a sloppy glob undermines caching across every machine. Before committing to turbo.json syntax at all, weigh it against the alternatives in Choosing a Monorepo Task Runner; the rest of this page assumes you have settled on Turborepo.
The problem statement
Turborepo only goes as fast as your declarations are honest. Every task needs three answers: what must run before it (dependsOn), what changes its result (inputs and env), and what it leaves behind (outputs). Miss any one and you get a wrong answer — a stale build, a non-deterministic hash, or a cache that never hits. The sections below make each answer explicit.
Core turbo.json schema and initialization
Initialize the pipeline at the repository root and bind the schema so your editor validates structure and autocompletes fields.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**/*.ts", "package.json", "tsconfig.json"],
"env": ["NODE_ENV"]
},
"lint": {
"dependsOn": [],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.ts", "test/**/*.ts"],
"outputs": ["coverage/**"]
}
}
}
Key directives:
$schemabinds to the published Turborepo schema for editor validation.tasksdefines the execution graph. Turborepo v2 renamedpipelinetotasks; on v1 the same object is namedpipeline.globalDependencieslists files that invalidate the entire cache when they change. Use it sparingly — only for truly global config like a root.env.
Task dependency graphs (dependsOn)
Turborepo builds its execution order from explicit dependsOn arrays. Unlike Nx Workspace Architecture, which infers much of the graph from project targets, Turborepo asks you to state the edges directly.
| Syntax | Behavior | Use case |
|---|---|---|
"^build" |
Run build in all upstream workspace dependencies first |
Cross-package compilation chains |
"build" |
Run build in the current workspace only |
Self-contained or sibling tasks |
"$TURBO_DEFAULT$" |
Inherit the default task config when extending | Reducing boilerplate in large repos |
# Visualize the resolved execution order without running anything
turbo run build --dry=json | jq '.tasks[].taskId'
# Emit the dependency graph as a DOT file
turbo run build --graph=graph.dot
The caret matters. Omitting ^ on a task that depends on shared libraries lets downstream packages run before their dependencies compile, producing MODULE_NOT_FOUND errors or stale type definitions.
The dependsOn field is where a pipeline encodes ordering, and the caret prefix is its most important piece of syntax. dependsOn: ["^build"] means a task waits on the same task in its dependencies — build every package I depend on before building me — while a bare dependsOn: ["build"] means a task waits on another task within the same package, such as a test that must run after that package's build. Combining them lets you express the full ordering a monorepo needs: test depends on the local build, and build depends on ^build, so the whole graph runs in dependency order.
Getting these edges right is a correctness matter, not just an optimization. A missing ^build edge lets a package build before its dependency's artifacts exist, producing a flaky failure that reproduces only under certain scheduling; an unnecessary edge serializes work that could run in parallel, slowing the pipeline. The task graph is only as correct as the dependency graph beneath it, so the same accurate internal dependencies that make resolution work make the pipeline order correct.
Persistent and interactive tasks
Not every task produces an artifact. A dev server runs forever; a watcher never exits. Turborepo needs to know this so it does not wait for the task to finish or try to cache its (nonexistent) output. Mark such tasks persistent and disable caching.
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
A persistent: true task cannot be a dependency of another task — Turborepo refuses to build a graph where a never-ending task blocks a downstream one, which catches the common mistake of listing dev in another task's dependsOn. Pair this with --continue in CI for batch tasks and reserve persistent tasks for local development entrypoints.
Not every task fits the cache-and-replay model, and Turborepo distinguishes them explicitly. A development server or a watch process is persistent — it never exits, so it has no final output to store — and marking it as such keeps it out of the cached build graph and signals to the scheduler that dependent tasks should not wait for it to finish. Interactive tasks that need a TTY are handled similarly. Recognizing which tasks are long-running or interactive, and configuring them as persistent, prevents the confusing situation where a pipeline appears to hang waiting on a server that is doing exactly what it should.
Marking a task uncacheable is the related tool for tasks whose output legitimately varies between runs. A task that embeds a timestamp, reads a live external service, or otherwise is non-deterministic cannot be safely cached, because a replayed result would be wrong; setting cache: false tells Turborepo to always execute it. The discipline is to reserve cache: false for genuinely non-deterministic work and to make everything else deterministic — declaring its env inputs and writing only to declared outputs — so the cache accelerates the maximum amount of work while never replaying a stale or wrong result.
Cache hashing and deterministic outputs
A task's cache boundary is defined by inputs (what triggers a rebuild) and outputs (what gets stored). Misconfigured globs cause either cache bloat or perpetual misses.
# Scope execution to packages affected since the last commit
pnpm exec turbo run build --filter='...[HEAD^1]'
# Inspect per-task hashes and hit/miss state
turbo run build --log-order=stream --output-logs=hash-only
Glob rules:
outputsmust capture only deterministic artifacts; always exclude volatile directories such as.next/cache,node_modules, and.turbo(use a!-prefixed glob).inputsshould be restricted to source files. Avoid**/*, which sweeps lockfiles, CI metadata, and editor cruft into the hash.- Pairing the pipeline with pnpm Workspace Filtering lets you invalidate and rebuild only the packages a change touches, cutting CI cost on incremental pull requests.
Turborepo's cache is correct only if the hash captures everything that affects a task's output, which is why declaring inputs and outputs precisely is the heart of a reliable pipeline. The hash mixes the task's source files, its dependencies' relevant files, the resolved graph, the task configuration, and any declared environment variables; the declared outputs tell Turborepo what to store and restore. Omit an input and the cache either misses when it should hit or, worse, replays a stale result; omit an output and a restore is incomplete, which usually forces you to disable caching for that task.
Determinism is the property that makes caching safe to trust. A task whose output depends only on its declared inputs produces the same result for the same hash, so a cache hit is provably equal to a rerun; a task that reads an undeclared env var, embeds a timestamp, or writes outside its declared outputs breaks that equality and should either be made deterministic or marked uncacheable. The discipline is to make every cacheable task a pure function of its declared inputs, so the cache is an accelerator you can rely on rather than a source of subtle staleness.
Environment variable security and passthrough
Turborepo does not inherit host shell variables into the hash by default — you declare them. This keeps cache keys stable and prevents secrets from silently entering an artifact.
| Field | Scope | Cache impact | Security posture |
|---|---|---|---|
env |
Task-level | Changes invalidate only that task | Recommended for API keys, feature flags |
globalEnv |
Repository-wide | Changes invalidate every task | Reserve for compiler flags (CC, CXX) |
passThroughEnv |
Task-level | Passes host vars without hashing them | Never for secrets; breaks determinism |
{
"tasks": {
"deploy": {
"dependsOn": ["build"],
"env": ["AWS_REGION", "DEPLOY_ENV"],
"outputs": []
}
}
}
Never declare TURBO_TOKEN, NPM_TOKEN, or GITHUB_TOKEN in globalEnv; scope deployment credentials to the deploy task only. Strict environment scoping correlates directly with cache-hit stability, as quantified in Nx vs Turborepo Performance Benchmarks.
Declaring environment inputs is what makes the cache both correct and portable, because a task that reads an undeclared variable has an input Turborepo cannot see:
// turbo.json
{
"globalEnv": ["NODE_ENV"],
"globalDependencies": [".nvmrc", "tsconfig.base.json"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["API_URL", "PUBLIC_BASE"],
"outputs": ["dist/**"]
}
}
}
Listing API_URL in the task's env folds its value into the hash, so two environments with the same value hash identically and different values correctly miss — instead of silently sharing an artifact built with the wrong value. globalEnv and globalDependencies capture workspace-wide inputs. When a cache mysteriously misses between CI and a laptop, the cause is almost always a variable read but not declared here, and the dry-run hash output names it.
Per-package overrides and configuration inheritance
A single root turbo.json is the simplest layout, but real monorepos have packages with genuinely different build shapes — an app that emits .next/**, a library that emits dist/**, a docs site that emits build/**. Turborepo lets a package ship its own turbo.json that extends the root, overriding only the fields that differ. This keeps the root definition the shared baseline rather than a dumping ground of special cases.
// packages/web/turbo.json
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
The "extends": ["//"] line points at the root configuration; the build block here merges over the root's build, replacing its outputs while inheriting dependsOn and inputs. Use this sparingly — every override is a place where the build behaves differently per package, so prefer a consistent root definition and reach for overrides only when a package's artifact layout truly differs.
Output log modes
How much a task prints is itself a tuning knob, because in CI the log volume becomes ingestion cost and signal-to-noise. The --output-logs flag controls what a cached task replays and what a fresh task streams.
| Mode | Behavior | When to use |
|---|---|---|
full |
Replay all task output, cached or not | Local debugging |
hash-only |
Print only the task hash and status | Verifying cache behavior |
new-only |
Show output only for tasks that actually ran | Default for readable CI |
errors-only |
Show output only for failed tasks | High-volume mainline CI |
# Readable CI: only freshly-run tasks print, cached ones stay quiet
turbo run build --output-logs=new-only
Turborepo's output modes control how much a task prints, which matters for readability in CI where dozens of tasks run at once. The default streams every task's output interleaved; a grouped mode collects each task's output together; and an errors-only mode surfaces just the failures, which keeps a green run quiet and a red run focused. Choosing the mode deliberately — grouped or errors-only for CI, streaming for local debugging — turns a wall of interleaved logs into something a human can actually read, so a failure in one package among many is immediately visible rather than buried in the parallel output of everything that succeeded.
Build orchestration and scripts
A clean pipeline pairs with clean scripts. The repository root typically exposes thin wrappers (turbo run build, turbo run test) that fan out to per-package scripts, rather than duplicating logic. Deciding what lives at the root versus inside each package is its own discipline — see Root-Level vs Package-Level Scripts for the division that keeps turbo.json readable.
// package.json (root)
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
}
}
Turborepo's value is that it turns a set of package scripts into a single orchestrated build without changing how each package defines its work. Each package keeps its own build, test, and lint scripts; turbo.json describes how those tasks relate across packages, and the runner derives the schedule. This separation means adopting Turborepo is incremental — wrap existing scripts as tasks, declare their dependencies and outputs — and a package remains responsible for its own implementation while the pipeline handles ordering, parallelism, and caching.
The orchestration composes with the package manager's own script running rather than replacing it. turbo run build invokes each package's build through the package manager, so the scripts a developer runs locally are the same ones CI runs through Turborepo, just ordered and cached. This consistency — the same scripts locally and in the orchestrated pipeline — is what keeps the build reproducible across environments and avoids the drift that comes from a CI-only build path that developers never exercise.
CI/CD pipeline integration and remote caching
Run pipelines with explicit concurrency, remote-cache authentication, and change-based filtering.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # required for --filter to compute a diff
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build and test affected
run: |
pnpm exec turbo run build test \
--concurrency=4 \
--filter='...[origin/main]'
timeout-minutes: 15
Production flags worth pinning:
| Flag | Purpose | CI recommendation |
|---|---|---|
--force |
Bypass the cache | Debugging only; never in mainline CI |
--filter |
Target a subset of workspaces | '...[HEAD^1]' on PRs, '...[origin/main]' on main |
--concurrency |
Bound parallel workers | Set to the runner vCPU count to avoid OOM |
--output-logs=errors-only |
Trim log volume | Reduce ingestion cost in CI |
How inputs, globalDependencies, and the lockfile compose
The single biggest source of confusion is which files actually feed a task's hash, because three different mechanisms contribute and they stack. A task's hash folds together: the hashed contents of every file matched by that task's inputs (or, if inputs is omitted, every committed file in the package); the contents of every file in globalDependencies; the resolved values of the variables in env and globalEnv; the hashes of the upstream tasks named by dependsOn; and the package manager lockfile, which Turborepo always factors in so a dependency bump invalidates correctly.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json", ".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.test.ts"],
"outputs": ["dist/**"]
}
}
}
Two refinements are worth knowing. $TURBO_DEFAULT$ inside an inputs array means "the default set of committed files, plus the extra patterns I list," so you can exclude test files from a build's hash without re-enumerating the whole source tree. And anything in globalDependencies invalidates every task in the repo, which is why a root tsconfig.base.json belongs there but a package-specific config does not — putting too much in the global list quietly defeats incremental caching across the entire workspace.
These three inputs together determine both the correctness and the portability of the cache. inputs narrows or widens which of a package's files feed its hash, letting you exclude a changelog or include a config outside the package; globalDependencies names non-source files — a base tsconfig, a root config — whose change should invalidate everything; and the lockfile is folded in automatically because the resolved dependency graph is an input to every task. A cache that misses between CI and a laptop has a divergent member of this set, and a cache that wrongly hits has an undeclared one — so composing them precisely is what makes the cache both stable enough to hit and complete enough to be correct.
Common pitfalls and mitigation
| Mistake | Impact | Resolution |
|---|---|---|
Omitting outputs arrays |
0% hit rate; artifacts regenerated every run | Declare exact globs (dist/**, build/**) |
globalEnv for credentials |
Secrets exposed to all tasks; full invalidation on rotation | Move to task-level env |
| Implicit shell env inheritance | Non-deterministic builds across runners | Declare every required var in turbo.json |
Missing ^ in dependsOn |
Downstream runs before upstream compiles | Use "^task" for workspace deps |
| Caching volatile dirs | Payload bloat; slow uploads | Exclude with a !-prefixed outputs glob |
Environment variables, inputs, and cache correctness
Environment variables are the most common cause of both cache misses and incorrect cache hits, because a task that reads an env var has an input Turborepo cannot see unless you declare it. Listing a variable in a task's env (or workspace-wide in globalEnv) folds its value into the hash, so two environments with the same value hash identically and different values correctly miss — instead of silently sharing an artifact built with the wrong value. The inputs field similarly narrows or widens what files feed the hash, letting you exclude a changelog or include a config file that lives outside the package.
How inputs, globalDependencies, and the lockfile compose determines whether the cache is both correct and portable across CI and local machines. globalDependencies names non-source files — a base tsconfig, a root config — that should invalidate everything when they change; the lockfile is folded in automatically because the resolved graph is an input. When a cache mysteriously misses between CI and a laptop, the cause is almost always a hashed input that differs: an undeclared env var, an unpinned tool version, or an absolute path leaking in. Printing the dry-run hash inputs in both environments and diffing them names the divergence immediately, turning a frustrating miss into a one-line fix.
Per-package overrides and CI integration
A pipeline defined once at the root can be overridden per package where a package genuinely differs, which keeps the common case simple without forcing every package into an identical shape. A package that needs an extra build output, a different set of inputs, or a task the others do not have can declare a package-level configuration that extends the root pipeline, so the shared defaults cover most packages and the exceptions are explicit and local. Overusing overrides fragments the pipeline into special cases nobody can reason about, so the goal is a strong root default with a small number of deliberate, documented exceptions.
In CI, the pipeline composes with remote caching to make the whole thing fast across runners. A CI job running turbo run build test --filter='...[origin/main]' builds and tests only the affected set, and with a remote cache configured, any task already computed on another runner — or a developer's machine — is downloaded rather than recomputed. Scoping cache write access to trusted branches keeps a pull request from poisoning the shared cache, and the combination of affected filtering, local replay, and remote sharing is what keeps a large monorepo's pipeline fast as both the code and the team grow.
Frequently Asked Questions
What is the difference between env and globalEnv in turbo.json?
env scopes a variable to one task, so its cache key changes only when that variable changes; globalEnv applies to every task and invalidates the whole cache when any listed variable changes. Use env for task-specific configuration and reserve globalEnv for truly global compiler flags.
How does Turborepo handle cross-package dependencies during execution?
The ^ prefix in dependsOn (such as "^build") tells Turborepo to run the named task in every upstream workspace dependency before the current one, producing a strict topological order without manual script chaining or && operators.
Why does my task rebuild every time even though nothing changed?
Either outputs is missing — so there is nothing to restore — or inputs is too broad and is hashing a file that changes on every run. Narrow inputs to source files and confirm outputs captures the real artifact directory.
Can I use Turborepo with non-JavaScript toolchains?
Yes. Turborepo operates on filesystem outputs and declared environment variables, so it is language-agnostic. Point outputs and inputs at your toolchain's artifact and source paths (for example target/ for Rust) and exclude temp directories to keep the hash deterministic.
What's the difference between ^build and build in dependsOn?
^build waits on the same task in a package's dependencies — build my dependencies first. A bare build waits on another task within the same package — for example, a test that must run after this package's build. Combining them expresses the full cross- and intra-package ordering.
Why does my Turborepo cache miss between CI and local?
A hashed input differs — usually an undeclared env var, an unpinned tool version, or an absolute path. Declare every input the task reads in env/inputs, pin toolchain versions, and diff the dry-run hash inputs between environments to find the divergence.
What's the most common Turborepo misconfiguration?
An incomplete outputs declaration or an undeclared env input. A task with no declared outputs never caches, and one that reads an undeclared variable can replay a stale artifact or miss between environments. Declaring inputs and outputs precisely is what makes the cache both fast and correct.
How do I debug a Turborepo cache problem?
Run the task with --summarize or --dry=json to see its hash and the inputs that fed it. A task that never caches has no declared outputs; one that misses unexpectedly has an unstable or undeclared input. Reading the summary turns caching from a black box into an inspectable system.
What does dependsOn: ["^build"] mean?
The caret means a task waits on the same task in its dependencies — build every package I depend on before building me. A bare build (no caret) waits on another task within the same package. Combining them expresses the full cross- and intra-package ordering a monorepo needs.
Related
- Nx vs Turborepo Performance Benchmarks — how pipeline declarations affect measured cold and warm build times.
- Remote Caching Setup — share the artifacts this pipeline produces across machines and CI.
- Choosing a Monorepo Task Runner — confirm Turborepo fits before investing in its schema.
- Root-Level vs Package-Level Scripts — the script layout that keeps
turbo.jsona thin orchestration layer.