Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

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.

Turborepo task graph A build task depends on upstream builds via caret-build, consumes declared inputs, and emits cached outputs that feed test and lint. inputs src, tsconfig ^build upstream deps build task hash = inputs + deps outputs dist (cached)
A build task hashes its inputs plus upstream dependency hashes, then emits cacheable outputs that downstream tasks consume.

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.

The problem statement Turborepo only goes as fast as your declarations are honest. The problem statement Turborepo only goes as fast as your declarations are honest.
The problem statement — the core idea of this section at a glance.

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.

turbo.json anatomy The core keys every pipeline task declares. dependsOn ordering edges inputs what invalidates outputs what to cache cache / env correctness controls
A task is defined by its dependencies, inputs, outputs and cache flag.
{
  "$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:

  • $schema binds to the published Turborepo schema for editor validation.
  • tasks defines the execution graph. Turborepo v2 renamed pipeline to tasks; on v1 the same object is named pipeline.
  • globalDependencies lists 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.

dependsOn graph ^build waits on package deps; build gates test. ^build upstream deps first build this package test dependsOn build after artifacts
dependsOn wires the whole repo into one ordered graph.
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.

Persistent and interactive tasks Not every task produces an artifact. Persistent and interactive tasks Not every task produces an artifact.
Persistent and interactive tasks — the core idea of this section at a glance.
{
  "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.

Deterministic hashing Inputs hash to a key; declared outputs are restored on a hit. inputs hash files + env + deps cache key content-addressed restore outputs dist/** replay
Only declared inputs and outputs make caching correct.
# 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:

  • outputs must capture only deterministic artifacts; always exclude volatile directories such as .next/cache, node_modules, and .turbo (use a !-prefixed glob).
  • inputs should 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.

Environment variable security and passthrough Turborepo does not inherit host shell variables into the hash by default — you declare them. Environment variable security and passthrough Turborepo does not inherit host shell variables into the hash by default — you declare them.
Environment variable security and passthrough — the core idea of this section at a glance.
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.

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 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 th
Per-package overrides and configuration inheritance — the core idea of this section at a glance.
// 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.

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. 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.
Output log modes — the core idea of this section at a glance.
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.

Build orchestration and scripts A clean pipeline pairs with clean scripts. Build orchestration and scripts A clean pipeline pairs with clean scripts.
Build orchestration and scripts — the core idea of this section at a glance.
// 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.

CI/CD pipeline integration and remote caching Run pipelines with explicit concurrency, remote-cache authentication, and change-based filtering. CI/CD pipeline integration and remote caching Run pipelines with explicit concurrency, remote-cache authentication, and change-based filtering.
CI/CD pipeline integration and remote caching — the core idea of this section at a glance.
# .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.

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 co 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.
How inputs, globalDependencies, and the lockfile compose — the core idea of this section at a glance.
{
  "$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
Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows. Common pitfalls and mitigation Common pitfalls and mitigation in production JavaScript package workflows.
Common pitfalls and mitigation — the core idea of this section at a glance.

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.

Cache hash inputs Source, deps, config, and declared env feed the key. source + deps hashed files task config + outputs what to store declared env values that matter globalDependencies root files
Declaring every input — especially env vars — makes the cache correct and portable.

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.

Pipeline in CI Root defaults, per-package exceptions, affected + remote cache. root pipeline shared defaults filter affected changed set remote cache replay across runners
A strong root default plus affected filtering and a remote cache keeps CI fast at scale.

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

Monorepo Architecture & Orchestration