turbo.json Explained: Turborepo Pipelines, Tasks, and Caching

Last updated:

turbo.json is the configuration file for Turborepo, Vercel's build system for JavaScript and TypeScript monorepos. It lives at the repository root next to package.json and describes every task in the workspace — which depends on which, what each reads and writes, and which results are cacheable. Turborepo 2.x renamed the top-level pipeline key from 1.x to tasks; almost every tutorial older than 2024 still shows the old key, which is the single biggest footgun for newcomers. The killer feature underneath all of this is content-based hashing — Turborepo caches task outputs by a hash of inputs (source files, env vars, internal-dependency versions), so any task whose inputs have not changed is skipped entirely and its previous output replayed from disk.

Editing a turbo.json by hand? Paste it into Jsonic's JSON Validator to catch trailing commas, mismatched braces, and quote mistakes before turbo rejects the file.

Validate turbo.json

The minimum turbo.json (2.x)

A working turbo.json for Turborepo 2.x needs only the $schema hint and a tasksobject. Each task name must match a script name that exists in at least one workspace's package.json.

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

What this says in plain English: before building a package, build every internal dependency first (^build); cache the dist and .next directories but explicitly NOT .next/cache; tests also need upstream builds first; lint has no dependencies and no outputs (only its stdout is cached); dev never reads or writes the cache and runs forever (persistent: true).

The $schema URL gives editors like VS Code autocomplete and validation for free. Use https://turborepo.com/schema.json (the modern URL); the older turbo.build/schema.json still resolves but Vercel rebranded the docs domain in 2024.

Tasks: dependsOn, outputs, inputs, cache, persistent

Every entry inside tasks is configured with the same handful of fields. The table below covers each one — they are the entire surface area you need to know.

FieldTypeWhat it doesDefault
dependsOnstring[]Tasks that must run first. Prefix with ^ for upstream packages, no prefix for same-package tasks.[]
outputsstring[]Glob patterns for files Turborepo should cache and restore. Negate with ! (e.g. !.next/cache/**).[] (stdout only)
inputsstring[]Glob patterns of files that affect the cache hash. Narrow to skip cache busts from unrelated changes.All tracked files in the package
cachebooleanWhether the task is cacheable. Set false for dev servers, deploys, publishes.true
persistentbooleanMarks long-running tasks (dev servers). Turbo refuses to depend on persistent tasks.false
envstring[]Env vars whose values feed this task's hash and are visible to the script.[]
passThroughEnvstring[]Env vars forwarded to the script but NOT included in the hash. Use for tokens/secrets.[]
outputLogsstringHow much output to print on cache hit: full, hash-only, new-only, errors-only, none.full
interactivebooleanAllows the task to receive stdin. Implies cache: false and persistent: true.false

A realistic build task uses most of them at once:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build", "codegen"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"],
      "passThroughEnv": ["GITHUB_TOKEN"],
      "outputLogs": "new-only"
    }
  }
}

Pipeline 1.x → tasks 2.x migration

Turborepo 2.0 (June 2024) was the first major-version bump in three years and broke compatibility in two notable places. If you are reading any tutorial published before mid-2024, mentally rename pipeline to tasks as you go.

ConceptTurborepo 1.xTurborepo 2.x
Top-level task map"pipeline""tasks"
Strict env modeOpt-in via "experimentalUI" / loose defaultStrict by default — only vars in env/globalEnv/passThroughEnv are visible
Filter syntax--filter with workspace globsSame, plus richer expressions (--filter="./apps/*")
Schema URLturbo.build/schema.jsonturborepo.com/schema.json (preferred)
Codemodn/anpx @turbo/codemod migrate

The migration is mostly mechanical. Run the official codemod:

npx @turbo/codemod@latest migrate

It rewrites turbo.json, updates the turbo dependency inpackage.json, and flags any task that relied on the old loose-env behaviour. After migration, run turbo run build --dry=json to verify the task graph still looks right before relying on the cache.

Caching: how the hash is built and how to invalidate it

Every task run produces a hash. The hash is the cache key. If a previous run with the same hash exists (locally in .turbo/ or remotely), Turborepo replays its stdout and restores its declared outputs instead of executing the script.

The hash is computed from:

  • Contents of every file matched by inputs (or every tracked file in the package if inputs is omitted)
  • Resolved values of every var listed in env + globalEnv
  • The package's package.json and the package.json of every internal dependency
  • Files matched by top-level globalDependencies
  • Hashes of all upstream tasks listed in dependsOn
  • The version of turbo itself

To invalidate the cache for a single task you can change any of those inputs. Common cases:

  • Edit a source file matched by inputs
  • Change the value of an env var in env (e.g., bump NEXT_PUBLIC_VERSION)
  • Bump the version of an internal dependency in its package.json
  • Add a path to globalDependencies (e.g., tsconfig.base.json) and modify it

To bypass the cache for one CLI invocation use turbo run build --force. To bypass for a specific task always, set "cache": false on that task. To wipe local cache entirely, delete the .turbo directories under each workspace.

A common gotcha: by default inputs includes every tracked file in the package, so unrelated changes (e.g., editing the README) still bust the cache. Narrow inputs to the directories your task actually reads: "inputs": ["src/**", "tsconfig.json"].

Remote caching: Vercel Remote Cache or self-hosting

Local caching saves time on your machine. Remote caching shares the cache between every developer and every CI run — so if a teammate already built the same commit, your build downloads their artifacts instead of recomputing them.

Vercel Remote Cache (the default, zero-config option):

npx turbo login
npx turbo link

That stores a token in ~/.turbo/config.json and tags the repo. In CI, set the same secrets as environment variables:

TURBO_TOKEN=<your-vercel-token>
TURBO_TEAM=<your-team-slug>

Self-hosted Remote Cache: the open-source turborepo-remote-cache project implements the same HTTP protocol Vercel uses and can run on AWS Lambda, GCP, Cloudflare Workers, or any Node.js host. Storage backends include S3, Azure Blob, GCS, and the local filesystem. Point turbo at it with two env vars:

TURBO_API=https://my-cache.example.com
TURBO_TOKEN=<shared-secret-your-server-validates>

Either way, the turbo.json itself does not change — remote caching is configured entirely through env vars and the turbo CLI. To verify remote-cache hits, run turbo run build --summarize and check .turbo/runs/<hash>.json for the cache field on each task.

Environment variables: env, globalEnv, passThroughEnv

Turborepo 2.x runs every task in a strict environment by default — only the env vars you explicitly whitelist are visible to scripts. This is a deliberate departure from 1.x and prevents accidental cache misses when developers have different shells.

FieldScopeIn hash?Visible to script?When to use
globalEnvEvery taskYesYesRepo-wide vars: NODE_ENV, CI, VERCEL_ENV
envOne taskYesYesVars that change output: NEXT_PUBLIC_API_URL, VITE_FEATURE_FLAGS
passThroughEnvOne taskNoYesSecrets that must reach the script but should not bust the cache: NPM_TOKEN, GITHUB_TOKEN
globalPassThroughEnvEvery taskNoYesRepo-wide secrets: VERCEL_TOKEN, AWS credentials
{
  "globalEnv": ["NODE_ENV", "CI", "VERCEL_ENV"],
  "globalPassThroughEnv": ["VERCEL_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_POSTHOG_KEY"],
      "passThroughEnv": ["GITHUB_TOKEN"]
    },
    "test": {
      "env": ["DATABASE_URL"]
    }
  }
}

Wildcards work in env arrays: "env": ["NEXT_PUBLIC_*"] matches every var with that prefix. Use this carefully — a wildcard means any new matching var busts the cache.

You can also load env from .env files via the per-task "inputs": ["$TURBO_DEFAULT$", ".env*"] pattern, which makes changes to env files contribute to the hash without needing to list each variable.

Workspace filtering and task graphs: --filter, ^build syntax

Filtering is how you tell turbo to run a task only in some workspaces. Common shapes:

# Single workspace by name (the name field in its package.json)
turbo run build --filter=@acme/web

# All workspaces under apps/
turbo run build --filter="./apps/*"

# A workspace and all its internal dependencies (transitive)
turbo run build --filter=@acme/web...

# A workspace and everything that depends on it (transitive, the other way)
turbo run test --filter=...@acme/ui

# Only workspaces changed since a git ref (great for CI)
turbo run build --filter="[origin/main]"

The ... ellipsis is the heavy hitter for CI: --filter=...A picks A and every package that depends on A, so you can run only the tests affected by a change. The [origin/main] syntax asks git which workspaces actually changed.

Inside dependsOn, the ^ prefix has the same "upstream" meaning you saw in the build example:

  • build — run the build task in this same workspace first
  • ^build — run the build task in every internal dependency first
  • codegen — run codegen in this workspace first
  • web#build — run the build task specifically in the web workspace first (rarely needed)

Visualize the resulting graph any time with turbo run build --graph, which writes a Graphviz DOT file you can render with dot -Tsvg. Use --dry=jsonto dump the task plan as JSON — extremely useful when debugging "why is this task running?" questions.

Key terms

Turborepo
An incremental build system for JavaScript/TypeScript monorepos, originally built by Jared Palmer and acquired by Vercel in 2021. Reads turbo.json to schedule tasks across workspaces in parallel, caches results by content hash, and shares the cache via Remote Caching.
Task pipeline
The directed graph of tasks Turborepo builds from your tasks definitions and the workspace dependency graph. Each node is a (package, task) pair; edges come from dependsOn. Turborepo runs the graph in topological order with maximum parallelism.
Content-based hashing
Turborepo's caching strategy: compute a hash from the contents of all inputs (source files, env vars, dep versions, upstream task hashes) and use it as the cache key. Identical inputs always produce identical hashes, so unchanged work is reused instead of repeated.
Remote cache
An HTTP server that stores cache artifacts shared between developers and CI. Vercel hosts the default implementation; the open-source turborepo-remote-cache project implements the same protocol on self-hosted infrastructure (S3, Cloudflare R2, etc.).
Persistent task
A long-running task (typically a dev server) marked with "persistent": true. Turborepo refuses to let any task dependsOn a persistent one, since it would never finish. Almost always paired with "cache": false.
Workspace filter
A --filter expression that selects which workspaces a turbo command applies to. Supports package names, directory globs (./apps/*), transitive selection (pkg... and ...pkg), and git-based selection ([origin/main]) for CI.

Frequently asked questions

What is turbo.json used for?

turbo.json is the configuration file for Turborepo, Vercel’s high-performance build system for JavaScript and TypeScript monorepos. It lives at the repository root next to package.json and tells Turborepo how every task in the workspace relates to every other task — which tasks depend on which, what files each task reads, what files each task writes, and which results are safe to cache. With that graph in hand, Turborepo can run tasks in the correct order, run independent tasks in parallel, skip any task whose inputs have not changed since the last run, and (optionally) share the cache with teammates and CI via Remote Caching. Without turbo.json, the turbo CLI has no idea what “build”, “test”, or “lint” mean across your workspaces.

Why does my turbo.json error say "pipeline is not allowed"?

You are running Turborepo 2.x against a turbo.json written for 1.x. The single biggest breaking change in the 2.0 release was renaming the top-level "pipeline" key to "tasks". The schema for what goes inside is otherwise nearly identical — dependsOn, outputs, inputs, cache, persistent, env all still work the same. Either run "npx @turbo/codemod migrate" which rewrites the file automatically, or rename the key by hand: change {"pipeline": { ... }} to {"tasks": { ... }}. If you maintain a project that still supports 1.x users, you can also pin turbo to 1.13.x until you migrate. The error message itself comes from turbo’s schema validator, which rejects unknown top-level keys in 2.x.

How does Turborepo decide what is in the cache?

Turborepo uses content-based hashing. For every task run it computes a hash from: the contents of every file matched by that task’s "inputs" globs (or, by default, every tracked file in the workspace), the resolved values of every environment variable listed in "env" and "globalEnv", the package.json contents of the workspace and its internal dependencies, the version of turbo itself, and the hashes of upstream tasks listed in "dependsOn". If the resulting hash matches a previous run, Turborepo replays the cached stdout and restores the files listed in "outputs" instead of executing the task. Change any input — a source file, an env var value, the version of an internal dependency — and the hash changes, so the task re-runs.

What is the difference between "env" and "globalEnv"?

"env" lives on a single task and lists environment variables whose values feed that task’s cache hash. Example: a Next.js build task lists NEXT_PUBLIC_API_URL under "env", so changing that variable busts only the build cache, not the test cache. "globalEnv" lives at the top level of turbo.json and applies to every task — use it for variables that affect the entire toolchain (NODE_ENV, CI, BUN_VERSION). A third related field, "passThroughEnv", forwards env vars into the task process without including them in the hash; use it for things like NPM_TOKEN that must reach the script but should not invalidate the cache. By default, Turborepo 2.x runs tasks in a strict environment and only the variables you whitelist in env/globalEnv/passThroughEnv are visible to scripts.

How do I make Turborepo skip the cache for a task?

Set "cache": false on that task in turbo.json. Common cases: a "dev" task that runs a long-lived dev server (also set "persistent": true), a "deploy" task whose output is a side effect on a remote system rather than files on disk, or a "publish" task that changes the npm registry. With "cache": false, Turborepo still uses the task to build the dependency graph and run things in the right order, but it never reads from or writes to the cache for that task. For a one-off, you can also pass --force on the CLI to bypass the cache for the whole run, or --no-cache to run without reading or writing the cache. Use cache: false in turbo.json when the task is fundamentally uncacheable; use --force as an ad-hoc override.

Can turbo.json have comments?

No — turbo.json is strict JSON per RFC 8259 and the Turborepo schema validator rejects any file with // line comments or /* block comments */. The accepted workaround is the "//" key, which the Turborepo schema explicitly ignores: {"//": "this turbo.json runs CI builds", "$schema": "...", "tasks": { ... }}. You can also place comments in a sibling file (a README or a turbo.notes.md) and reference it from the "//" key. If you need richer commentable config in your repo, keep turbo.json minimal and document the rationale in your monorepo README. See our JSON Comments guide for the general workaround pattern across JSON files.

What does ^build mean in dependsOn?

The caret prefix in dependsOn means “run this task in every internal dependency first.” So "dependsOn": ["^build"] on the build task tells Turborepo: before building this workspace, build every workspace it depends on. Without the caret, "dependsOn": ["build"] would mean “run the build task in this same workspace first”, which is rarely what you want for build itself. The caret is the standard way to express “topological” dependencies that flow up the package graph. You can mix both styles in one array: "dependsOn": ["^build", "codegen"] means “build every upstream package, and also run the codegen task in this package, before building.”

How do I set up remote caching without Vercel?

Turborepo speaks a simple HTTP cache API; any server that implements it can host your remote cache. The most popular self-hosted option is the open-source turborepo-remote-cache project (Fastify-based) which you can deploy to AWS, GCP, Cloudflare Workers, or any Node-capable host. Point turbo at it by setting two environment variables: TURBO_API (the base URL of your cache server) and TURBO_TOKEN (a shared secret your server validates). Optionally set TURBO_TEAM. Storage backends supported by turborepo-remote-cache include S3, Azure Blob, Google Cloud Storage, and the local filesystem. The Vercel Remote Cache is the path-of-least-resistance option (run "npx turbo login" then "npx turbo link"), but you are never locked in — the protocol is documented and the self-hosted servers are drop-in.

Further reading and primary sources