Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Migrating turbo.json from pipeline to tasks

Turborepo 2.0 renamed the top-level pipeline key in turbo.json to tasks, made environment variable handling strict by default, and changed or removed several other options. A repository that upgrades the turbo package without migrating its configuration fails immediately — or, more subtly, runs with builds missing environment variables they used to see. This guide runs the official codemod, explains each change it makes, covers the behaviour changes it cannot make for you, and verifies the result before merging. Treat the upgrade as a configuration migration with its own review, not as a routine dependency bump that a bot can merge unattended.

Exact symptoms and error messages

After upgrading turbo to 2.x without migrating:

$ pnpm turbo run build
  × Found `pipeline` field instead of `tasks`.
   ╭─[turbo.json:3:1]
 3 │   "pipeline": {
   ·   ─────┬────
   ·        ╰── Rename `pipeline` field to `tasks`
   ╰────
  help: Changed in 2.0: `pipeline` has been renamed to `tasks`.

Other 1.x options produce similar errors:

  × Found `outputMode` field instead of `outputLogs`.
  × Found deprecated `dotEnv` field. Use `inputs` with `$TURBO_DEFAULT$` and .env files instead.

After renaming by hand but not addressing environment variables, builds succeed with different output:

@acme/web:build: Error: Missing required env var NEXT_PUBLIC_API_URL
# or silently: the bundle contains "undefined" where the API URL should be

What changed in Turborepo 2.0

The configuration changes fall into renames the codemod can apply mechanically and behaviour changes that need decisions. The task model itself is covered in Turborepo Pipeline Configuration.

Turborepo 1.x configuration versus 2.x Maps 1.x turbo.json keys and defaults to their 2.x equivalents, marking which changes the codemod handles automatically. 1.x 2.x codemod? top-level key pipeline tasks yes log setting outputMode outputLogs yes env files dotEnv / globalDotEnv inputs with .env files yes env var mode loose by default strict by default decide yourself workspace required packageManager optional packageManager required adds if missing
Renames are automatic; strict environment mode is the change that needs a human decision.

The strict environment mode change is the one that bites. In 1.x, tasks saw the whole environment of the shell that ran turbo. In 2.x, tasks see only variables listed in env, globalEnv or passThroughEnv (plus a small set of system variables). A build that read NEXT_PUBLIC_API_URL without declaring it now sees nothing. This is deliberate: undeclared variables were also missing from the cache hash, so they produced wrong cache hits. The details are covered in Fixing Missing Environment Variables in Turborepo Strict Mode.

Running the migration

# Run all 2.0 codemods: renames, env file handling, packageManager field
pnpm dlx @turbo/codemod migrate

# Or step by step, to review each change
pnpm dlx @turbo/codemod rename-pipeline
pnpm dlx @turbo/codemod rename-output-mode
pnpm dlx @turbo/codemod migrate-dot-env
pnpm dlx @turbo/codemod add-package-manager

A 1.x configuration:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [".env"],
  "globalDotEnv": [".env"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "outputMode": "new-only"
    },
    "test": { "dependsOn": ["build"], "inputs": ["src/**", "test/**"] },
    "dev": { "cache": false, "persistent": true }
  }
}

After the codemod and a manual environment pass:

{
  "$schema": "https://turborepo.com/schema.json",
  "globalDependencies": [".env"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_*", "API_URL"],
      "outputLogs": "new-only"
    },
    "test": { "dependsOn": ["build"], "inputs": ["src/**", "test/**"] },
    "dev": { "cache": false, "persistent": true }
  }
}

The $TURBO_DEFAULT$ token keeps Turborepo's default inputs (all tracked files in the package) and adds .env* files on top, replacing the old dotEnv option. The env array uses wildcards to declare every NEXT_PUBLIC_* variable the framework inlines.

Migration steps for a Turborepo 2 upgrade Upgrade the turbo package, run the codemod, declare environment variables, compare task hashes and outputs, then merge. upgrade turbo to 2.x root devDependency keep the lockfile change in this PR @turbo/codemod migrate renames, dotEnv, packageManager declare env vars env, globalEnv, passThroughEnv search code for process.env dry run + compare turbo run build --dry=json full uncached build --force, then diff outputs
The codemod handles syntax; declaring environment variables is the step that protects correctness.

Finding the environment variables to declare

Search each package for environment reads and framework conventions:

# Direct reads in source
grep -rhoE "process\.env\.[A-Z0-9_]+" apps packages | sort | uniq -c | sort -rn

# Vite-style reads
grep -rhoE "import\.meta\.env\.[A-Z0-9_]+" apps packages | sort -u

# Run in strict mode and let Turborepo list what tasks tried to use
pnpm turbo run build --env-mode=strict --summarize

Declare variables that affect output under env (they are hashed). Variables that must be available but should not affect the hash — credentials for a package registry, CI metadata — go under passThroughEnv. Variables every task needs go under globalEnv.

As a temporary bridge, --env-mode=loose restores 1.x behaviour for a run, which is useful for confirming that a difference in output really is caused by environment filtering. Use it to unblock an upgrade, not as a permanent setting: loose mode brings back the stale-cache risk that strict mode removes.

Other 2.x changes worth checking

Beyond the codemod's renames and strict environment mode, a few smaller behaviour changes can surface during the upgrade.

Package manager and lockfile reading. Turborepo 2 reads the lockfile to understand external dependencies of each package and includes them in hashes. A lockfile that is out of date or written by a different package manager version can therefore change hashes (and cache hit rates) after the upgrade even when nothing else changed. Align the package manager version first, as described in Package Manager Version Management.

Workspace root tasks. Running tasks defined in the root package.json requires the //#task syntax in turbo.json (for example "//#lint:root"), and root tasks are not run implicitly by turbo run lint. Scripts that relied on the root being treated like any other package need an explicit root task entry.

Terminal UI. Turborepo 2 introduced an interactive terminal UI for local runs. It is optional ("ui": "tui" or "stream" in turbo.json), and CI runs default to streaming logs; if log parsing in CI changed after the upgrade, set "ui": "stream" explicitly.

Schema URL and configuration validation. The configuration is validated more strictly. Unknown keys that 1.x ignored now produce errors, which is a good opportunity to remove dead configuration left over from old experiments.

Diagnosing a failed or suspicious Turborepo 2 upgrade A chain of checks from configuration errors to missing environment variables, cache hit changes and root task behaviour. Does turbo reject turbo.json? Run the codemod pipeline, outputMode, dotEnv renames yes Do outputs differ from before? Declare env vars env, globalEnv, passThroughEnv yes no Did cache hit rates drop? Check lockfile + env hashing new inputs change hashes once yes no Check root tasks //#task entries for root scripts no
Configuration errors are loud; environment and hashing changes are quiet, so check them deliberately.

A one-time drop in cache hit rate right after the upgrade is expected, because hashes now include information they did not before. It should recover after the first full run on main populates the cache with new entries. A drop that persists points at an input that changes on every run — often an environment variable such as a build number declared under env instead of passThroughEnv.

Verifying the migration

# Inspect the resolved task graph, inputs and env for each task
pnpm turbo run build --dry=json > dry.json
jq '.tasks[] | {taskId, environmentVariables}' dry.json | head -40

# Build everything without cache and compare with a pre-upgrade build
pnpm turbo run build --force
diff -r dist-before/ apps/web/dist/ | head

The most reliable check is a before-and-after comparison, because it tests the thing you actually care about — that the same commit produces the same artefacts on both versions: build on the old version, save the outputs, build on the new version with --force, and diff. Differences almost always trace back to an environment variable that is now filtered out.

Worked example: the missing API URL

A team upgrades to Turborepo 2, runs the codemod, sees green CI, and deploys. The web application's production bundle calls undefined/graphql. NEXT_PUBLIC_API_URL was set in the CI environment and read by Next.js at build time, but strict mode filtered it out because turbo.json never declared it. Adding "env": ["NEXT_PUBLIC_*"] to the build task fixes the bundle and — because declared variables are part of the hash — also fixes a latent problem: previously, builds for staging and production with different API URLs had shared a cache entry.

Prevention and CI/CD guardrails

  • Upgrade Turborepo in a dedicated pull request with the codemod and nothing else.
  • Compare outputs before and after with an uncached build.
  • Declare every environment variable tasks read, and keep strict mode on.
  • Pin turbo exactly in devDependencies so the configuration and the binary always match.

Frequently Asked Questions

Can I stay on Turborepo 1.x? It keeps working with its own configuration, but it no longer receives features and newer remote cache and platform integrations target 2.x. Plan the migration rather than deferring it indefinitely.

Does the codemod change package turbo.json files too? Yes. It applies the same renames to package-level configurations that extend the root, described in Using Package-Level turbo.json Overrides.

Why does Turborepo 2 require the packageManager field? It uses the field to know which package manager and lockfile format to read when building the package graph, rather than guessing from files on disk.

Do I need to clear the remote cache after upgrading? No. Hashes computed by 2.x differ from 1.x hashes, so old entries are simply never matched and expire according to your retention policy. The first runs after the upgrade repopulate the cache.

How do I upgrade a repository with many package-level turbo.json files? Run the codemod from the repository root; it finds and updates every turbo.json in the workspace. Review each changed file, because package-level overrides are where hand-written environment settings most often live.

Can I test the upgrade without affecting other branches? Yes. The configuration and the turbo version live in the repository, so the upgrade is isolated to its pull request. Other branches keep running 1.x until they rebase onto the merged upgrade.

Related

Turborepo Pipeline Configuration