← All posts
·14 min read

Claude Code with Turborepo: Pipelines, Caching, and Monorepo Builds

Claude CodeTurborepoMonorepoWorkflow
Claude Code with Turborepo: Pipelines, Caching, and Monorepo Builds

Why Turborepo breaks Claude Code's defaults

Claude Code is excellent at working within a single package. It reads source files, writes code, runs commands, and iterates. The problem is that its defaults assume a single-package mental model: one package.json, one tsconfig.json, one set of build scripts.

Turborepo assumes the opposite.

A Turborepo workspace has multiple packages, each contributing tasks to a shared pipeline graph. The turbo.json at the root defines which tasks exist, how they depend on each other, what their inputs are, and where their outputs land. The build cache is deterministic by content hash. Remote caching means that if CI already built a package on the same inputs, your local machine skips the build entirely and pulls the artifact.

Without configuration, Claude Code treats your Turborepo workspace like a large single-package repo. It runs pnpm run build at the root when turbo build --filter=web would suffice. It edits turbo.json without understanding how dependsOn affects task ordering. It adds inputs arrays that are too broad, breaking cache hits for unchanged packages. It runs turbo build with --no-cache when debugging, then forgets to remove the flag.

The fix is a CLAUDE.md that encodes the Turborepo mental model. Once, at the root. Claude reads it before every session and applies it consistently.

For initial Claude Code installation and authentication, Claude Code setup guide covers the prerequisite steps. For the broader monorepo context, Claude Code for monorepos is worth reading alongside this guide. For the Nx alternative to Turborepo, Claude Code with Nx covers the project graph and affected commands approach.

The Turborepo CLAUDE.md template

Place this at the workspace root. Adjust the apps/ and packages/ layout, the remote cache provider, and the task names to match your project. The structural rules apply to any Turborepo workspace.

# Turborepo workspace rules

## Workspace layout
- apps/         : deployable applications (web, api, docs, storybook)
- packages/     : shared packages (ui, config, types, utils)
- turbo.json    : pipeline task graph (build, test, lint, dev, typecheck)
- pnpm-workspace.yaml : package discovery

## Package manager
- pnpm workspaces only. Never use npm or yarn commands.
- Node: 20.x (enforced via .nvmrc and engines field in root package.json)
- Add packages: pnpm add <pkg> --filter <workspace>

## turbo.json pipeline rules
- NEVER edit turbo.json without reading the current pipeline first
- dependsOn: ["^build"] means "wait for all dependency packages to build first"
- dependsOn: ["build"] means "wait for build in the SAME package to complete first"
- The ^ prefix is cross-package (topological). No prefix is same-package.
- inputs controls what breaks the cache. Be specific: ["src/**", "package.json", "tsconfig.json"]
- outputs controls what is cached and restored. Match your build output directory exactly.
- Misconfigured inputs = cache always misses. Misconfigured outputs = cache hits but produces nothing.

## Running tasks
- turbo build                        : build all packages in dependency order
- turbo build --filter=web           : build only the web app (and its dependencies)
- turbo build --filter=...web        : build web and all packages that web depends on
- turbo build --filter=web...        : build web and all packages that depend on web
- turbo build --filter=[HEAD^1]      : build only packages changed since the last commit
- turbo run test lint                 : run test and lint across all packages
- turbo dev --filter=web             : run dev for a single app

## Cache rules
- NEVER add --no-cache to scripts, package.json tasks, or CI configs
- --no-cache is for interactive debugging of cache correctness only
- If a task result looks stale: check inputs first (turbo build --dry=json)
- Local cache: .turbo/ directory (safe to clear with turbo clean)
- Remote cache: Vercel Remote Cache (or self-hosted). Requires TURBO_TOKEN and TURBO_TEAM env vars
- Cache hits are expected for unchanged inputs. A cache hit is not an error.

## Daemon mode
- Turborepo daemon provides a persistent file-watcher process for faster subsequent runs
- Start: turbo daemon start | Stop: turbo daemon stop | Status: turbo daemon status
- Daemon is optional but recommended for local development on large workspaces
- Do NOT start the daemon in CI environments

## Package graph awareness
- Run "turbo build --dry=json" to see what would run without executing anything
- Run "turbo build --graph" to visualise the task dependency graph
- Changes to a shared package (packages/ui, packages/config) affect all consumers
- Before modifying a shared package: check which apps import it

## Docker with turbo prune
- Use "turbo prune --scope=web --docker" to generate a minimal dependency tree for Docker
- Output: out/json/ (package.json files only) and out/full/ (all files)
- Two-stage Dockerfile: COPY out/json first (deps), then COPY out/full (source)
- Never copy the entire workspace into a Docker image. Prune it first.

## What NOT to do
- Do NOT run "pnpm run build" at the root. Use turbo build.
- Do NOT add --no-cache to any shared script or CI configuration
- Do NOT edit turbo.json pipeline entries without reading existing dependsOn values
- Do NOT run turbo tasks in parallel manually (turbo handles parallelism from the graph)
- Do NOT add turbo.json pipeline entries for tasks that do not exist in any package.json
- Do NOT skip daemon status checks when debugging slow builds

The three sections that matter most are the dependsOn rules, the cache rules, and the --filter reference. They prevent the five highest-frequency Claude Code failures in Turborepo workspaces.

Five Claude defaults that break Turborepo workspaces

1. Running full workspace builds when --filter would suffice

Without guidance, Claude runs:

turbo build
# or
pnpm run build

In a 12-package workspace, this rebuilds every app and every shared package in dependency order. If you are working on the web app and its packages/ui dependency, the correct command is:

turbo build --filter=...web

The ...web syntax tells Turborepo to build web and all packages that web depends on, in the correct order, using cached outputs for anything that has not changed. A change to packages/ui triggers builds for web and any other app that imports it. A change inside apps/web/src/ only triggers the web build.

The CLAUDE.md entry enforces this: "NEVER run turbo build across all packages for a change scoped to one app. Use --filter with the scope that matches your change."

2. Misreading dependsOn and breaking task order

turbo.json pipeline dependencies are where Claude makes the most conceptually expensive mistakes. The difference between these two entries is significant:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}

"^build" in the build task means: before building this package, wait for all packages it depends on to finish building. This is the standard cross-package ordering that ensures packages/ui is built before apps/web tries to import from it.

"build" (without the caret) in the test task means: before running tests in this package, wait for the build target in the same package to complete. This is used when your test runner needs the compiled output, not the source.

Claude will sometimes write "dependsOn": ["build"] in a build task when "^build" was intended, or reverse the two. Either breaks the pipeline: missing ^ means packages can build in the wrong order, and using ^ where same-package ordering was intended adds unnecessary cross-package waits.

The CLAUDE.md clarifies: "dependsOn with ^ is cross-package. dependsOn without ^ is same-package. Do not swap them."

3. Broad inputs that kill the cache

Turborepo's cache is content-hash based. Inputs determine what Turbo hashes. If inputs are too broad, any file change in the workspace invalidates the cache for that task, even unrelated changes.

Claude will sometimes generate:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["**/*"],
      "outputs": ["dist/**"]
    }
  }
}

"**/*" as inputs means every file in the package, including README changes, test fixture changes, and .env.example updates. Any file touch causes a cache miss, which defeats the entire point of the cache.

The correct entry is explicit:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**"]
    }
  }
}

src/** covers your source. package.json catches dependency changes. tsconfig.json catches TypeScript configuration changes. Everything else is irrelevant to the build output and should be excluded.

Add to your CLAUDE.md: "inputs must be a specific list, never **/*. outputs must match the actual build output directory."

4. Ignoring remote cache configuration

Turborepo's remote cache is the feature that makes CI significantly faster for teams. When configured, Turborepo checks the remote cache before running any task. If the remote cache has a result for the current input hash, it downloads the artifact instead of rebuilding.

Two environment variables activate it:

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

Without these variables set, every CI run rebuilds from scratch even if the same code was built yesterday. Claude will not prompt you to set these variables because it does not know whether you have a Vercel account or a self-hosted Turborepo cache server.

Add to your CLAUDE.md:

## Remote cache
- Remote cache: Vercel Remote Cache
- Required env vars: TURBO_TOKEN, TURBO_TEAM (set in CI secrets, never commit)
- Verify cache is active: turbo build --dry=json | grep "cacheState"
- Self-hosted alternative: TURBO_API=https://your-cache-server turbo build

For self-hosted caching, Turborepo supports any server implementing the Remote Cache API. The TURBO_API variable points to your server. The token mechanism is the same.

5. Adding --no-cache to routine commands

Claude sometimes adds --no-cache when a build behaves unexpectedly:

turbo build --no-cache

For a one-off debugging session, this is reasonable. The problem appears when Claude adds --no-cache to shared scripts, CI configurations, or package.json tasks. Every subsequent run then bypasses both local and remote caches, effectively disabling the primary performance feature of Turborepo.

The CLAUDE.md rule is explicit: "--no-cache is for interactive debugging only. Never write it into any script, CI config, or package.json task."

If a task is producing stale output despite a cache hit, the correct debugging sequence is:

# 1. Inspect what Turborepo thinks will run and why
turbo build --dry=json

# 2. Check which files are included in inputs
turbo build --summarize

# 3. Clear only local cache and re-run
turbo clean && turbo build

# 4. Verify remote cache is being hit
turbo build --verbosity=2 | grep "FULL TURBO"

FULL TURBO in the output means all tasks were satisfied from cache. If you see it on a run where you expect fresh output, your inputs are likely too broad.

Configuring the turbo.json pipeline

A complete turbo.json for a Next.js + API monorepo:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json"],
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json", "next.config.*"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "env": ["NEXT_PUBLIC_API_URL"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "test/**", "vitest.config.*", "jest.config.*"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "inputs": ["src/**", ".eslintrc.*", ".eslintignore"],
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Key decisions in this pipeline:

The globalDependencies field lists files at the workspace root that affect all packages. A change to tsconfig.base.json invalidates every package's cache because every package extends it. Put shared config files here, not in each package's inputs.

The globalEnv field lists environment variables that affect all packages. Changes to these variables invalidate the cache across the workspace. List only the variables whose values actually affect build output, not operational secrets.

The dev task sets "cache": false and "persistent": true. Development servers do not produce cacheable output, and they run continuously. Marking them persistent tells Turborepo not to wait for them to exit before considering the pipeline complete.

The "!.next/cache/**" entry in build outputs excludes Next.js's internal webpack cache from what Turborepo restores. Restoring it would overwrite Next.js's own incremental compilation state.

Add a section in your CLAUDE.md pointing to this file:

## turbo.json location and editing rules
- Pipeline config: turbo.json at workspace root
- Before adding a task: confirm the task script exists in at least one package.json
- Before modifying dependsOn: read turbo build --dry=json output to understand current order
- dev task is always cache: false, persistent: true
- globalDependencies lists workspace-root files that affect all package caches

Using turbo prune for Docker

turbo prune is the correct way to produce a minimal Docker build context for a single app in a Turborepo workspace. Without it, your Docker build copies the entire workspace, which includes every app, every shared package, and all their node_modules.

turbo prune --scope=web --docker

This generates an out/ directory with two subdirectories:

  • out/json/: package.json files only, preserving the workspace structure needed for pnpm install
  • out/full/: the full source tree for web and only the packages it depends on

A two-stage Dockerfile that uses this output:

FROM node:20-alpine AS installer
WORKDIR /app

# Install pnpm
RUN corepack enable

# Copy package.json files first (better layer caching)
COPY out/json/ .
COPY pnpm-workspace.yaml .
RUN pnpm install --frozen-lockfile

# Copy full source
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=installer /app .
COPY out/full/ .

RUN pnpm run build --filter=web

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./.next/static
COPY --from=builder /app/apps/web/public ./public

EXPOSE 3000
CMD ["node", "server.js"]

The two-stage pattern keeps layer caching effective. The out/json/ layer (package.json files + pnpm install) is invalidated only when dependencies change. The out/full/ layer is invalidated when source changes. Splitting them means a pure source change rebuilds in the second stage only, without reinstalling dependencies.

Add to your CLAUDE.md:

## Docker rules
- Always use turbo prune --scope={app} --docker before building a Docker image
- Never COPY the full workspace root into a Docker build context
- Two-stage Dockerfile: out/json/ for install layer, out/full/ for build layer
- Run prune from workspace root, not from the app directory

Claude will default to copying the full monorepo into Docker if this rule is absent. A full workspace copy with all packages and hoisted node_modules produces images that are often 2-4x larger than a pruned build.

Working with packages across the workspace

Turborepo's --filter flag is the primary tool for scoping Claude's work to the right packages. The filter syntax is more expressive than it looks:

# Single package by name
turbo build --filter=web

# Package and all its dependencies
turbo build --filter=...web

# Package and all packages that depend on it
turbo build --filter=web...

# All packages changed since a specific commit
turbo build --filter=[HEAD^1]

# All packages changed since branching from main
turbo build --filter=[main...HEAD]

# Packages matching a glob (all apps)
turbo build --filter="./apps/*"

# Combine filters
turbo build --filter=web --filter=api

Add this reference to your CLAUDE.md as a lookup table. Claude will use --filter=web for straightforward cases but may not reach for --filter=...web (build with all dependencies in order) or --filter=[HEAD^1] (changed packages only) without being told they exist.

The --filter=[HEAD^1] variant is particularly useful for Claude Code sessions: it runs tasks only on the packages you have touched in the current working session, which aligns exactly with what you want to validate before committing.

FAQ

Does turbo run commands execute in parallel by default?

Yes. Turborepo runs all tasks that are not blocked by dependsOn relationships in parallel, up to the available CPU count. You do not need to configure parallelism manually. If you need to limit concurrency (for memory-constrained CI), use --concurrency=4.

How do I add a new package without breaking the pipeline?

Create the package directory under apps/ or packages/, add a package.json with a name field that matches your workspace naming convention, and add the task scripts that your turbo.json pipeline references (build, test, lint). Turborepo discovers packages from pnpm-workspace.yaml automatically. No changes to turbo.json are needed unless the new package has different task ordering requirements.

What happens if I run turbo build and some tasks fail?

Turborepo exits with a non-zero code and prints which tasks failed. Tasks that do not depend on the failed task may still complete. The local cache stores results for tasks that succeeded. On the next run, successful tasks use their cached output and only failed tasks re-run. This is different from a sequential bash script where a failure stops everything.

Can I use Turborepo remote caching without a Vercel account?

Yes. The Turborepo Remote Cache API is open. Self-hosted options include turborepo-remote-cache (the official open-source server), Buildkite Remote Cache, and custom implementations. Set TURBO_API to your server URL, TURBO_TOKEN to your auth token, and TURBO_TEAM to your team identifier. The caching behaviour is identical regardless of provider.

How does Claude Code interact with the Turborepo daemon?

Claude Code runs commands as subprocesses. The daemon is a background process that maintains a file-watcher. If you start the daemon manually (turbo daemon start), subsequent turbo commands in the same environment use it automatically. Claude does not need to know about the daemon explicitly, but your CLAUDE.md should note that daemon status is the first check when local builds feel slower than expected.


A Turborepo workspace gives you incremental builds, content-hash caching, and remote artifact sharing. Claude Code gives you intelligent code generation across packages. The combination only works when Claude understands the pipeline model, the filter syntax, and the cache contracts. The CLAUDE.md template above encodes that model so it applies from the first command of every session.

For GitHub Actions integration that runs turbo affected in CI, Docker workflows that use turbo prune, or the TypeScript conventions that span your workspace packages, the pattern holds: write the rules once in CLAUDE.md, enforce them in configuration, and let Claude execute within those constraints.

Claudify ships Turborepo-ready CLAUDE.md templates, pipeline defaults tested on real production monorepos, and remote cache configuration patterns. One command: npx create-claudify.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir