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.
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.
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.
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.
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
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"
}
}
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.
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 |
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.
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.