An opinionated production reference for Turborepo monorepos — workspace structure, internal packages, task graph design, caching, environment variables, CI/CD, and deploy strategies. Based on Turborepo 2.9. Not a tutorial.
This is the reference I built from running Turborepo across every project since 2022 — from a two-app portfolio monorepo to a 600K LOC AI platform with 10 apps and 14 packages. The patterns here are what survived contact with real codebases.
Every section answers one question: how do I actually design this for production?
Covers Turborepo 2.9 (March 2026). Key new features: up to 96% faster Time to First Task, turbo query stable, turbo.jsonc support, OpenTelemetry (experimental), structured logging (--json, --log-file), Future Flags for 3.0 migration.
A monorepo earns its complexity only when you have code that should be shared across multiple applications. If your apps evolve independently with minimal shared code, separate repositories are simpler.
When to use a monorepo:
✅ Multiple apps share UI components, hooks, types, or utilities
✅ You want one PR to update a shared package and all consumers simultaneously
✅ You want unified TypeScript, ESLint, and Prettier configuration
✅ You need atomic cross-package changes
❌ Apps are truly independent (different teams, different deployment cycles)
❌ You have one app and no plans for more
❌ The shared code is better as an npm package
The monorepo tax: initial setup complexity, CI configuration overhead, and occasionally surprising cache behavior. These are real costs — be honest about whether the benefits outweigh them for your situation.
my-monorepo/
├── apps/ # Applications — deployed, not installed into other packages
│ ├── web/ # Next.js frontend
│ ├── docs/ # Documentation site
│ └── api/ # Backend API
├── packages/ # Libraries and tooling — installed into apps and each other
│ ├── ui/ # Shared React components
│ ├── config-ts/ # Shared TypeScript config
│ ├── config-eslint/ # Shared ESLint config
│ └── utils/ # Shared utilities
├── package.json # Root — workspace definition + devDependencies for repo management
├── turbo.json # Task pipeline definition
└── pnpm-workspace.yaml (or bun.lockb / .npmrc for npm/yarn)
The apps/ vs packages/ split is a convention, not a requirement. What matters: apps are deployed and not imported by other packages; packages are libraries imported by apps and other packages.
The packageManager field is important — Turborepo uses it to detect the package manager and stabilize lockfile parsing. Without it, cache invalidation can be unpredictable.
Pros: Zero build step, fastest iteration, no dist/ to worry about.
Cons: Only works when every consumer uses a bundler that understands TypeScript. Cannot be cached by Turborepo (no build outputs). Cannot use compilerOptions.paths in TypeScript.
2. Compiled Package
Has its own build step. Turborepo can cache the build output.
Use a namespace prefix to avoid npm registry conflicts. @repo/ is an unused, unclaimable namespace — ideal for internal packages that won't be published.
"dependsOn": ["^build"] → run build in all dependency packages first (topological)
"dependsOn": ["build"] → run build in the SAME package first
"dependsOn": [] → no dependencies, run whenever
"dependsOn": ["lint", "^build"] → run this package's lint AND all deps' build first
^ is the topological operator — "all packages I depend on." Without ^, it means "this package."
# See why a task missed cacheturbo build --summarize# Outputs .turbo/runs/*.json with full hash inputs# See what turbo would run without running itturbo build --dry-runturbo build --dry-run=json | jq '.tasks[] | select(.cache.status == "MISS")'# Force re-run ignoring cache (writes to cache after)turbo build --force# Visualize what would runturbo build --graph# OpenTelemetry (2.9+, experimental)# turbo.json: { "futureFlags": { "experimentalObservability": true } }# Sends metrics to your OTLP backend
❌ Tasks with network side effects (API calls, DB mutations)
❌ Dev servers (always use cache: false, persistent: true)
❌ Tasks faster than cache overhead (sub-100ms tasks)
❌ Tasks with enormous outputs (>500MB Docker images)
# Run a task across all packagesturbo buildturbo devturbo lint# Run only for specific packagesturbo build --filter=webturbo build --filter=@repo/uiturbo build --filter=./apps/web# Run for a package and all its dependenciesturbo build --filter=web...# Run for packages that changed vs mainturbo build --affected# Run for packages in a directoryturbo build --filter="./apps/*"
turbo dev # automatically uses TUI# Force specific UIturbo build --ui=tui # interactiveturbo build --ui=stream # traditional log streaming (good for CI)
In the TUI: use arrow keys to navigate packages, press Enter to focus a package's logs, Ctrl+Z to exit focus.
Query your monorepo structure with GraphQL or shorthands:
# Open interactive GraphiQL playgroundturbo query# Which packages are affected by current changes?turbo query affected# Which packages would be affected if I changed packages/ui?turbo query affected --packages# Which tasks would run if I changed packages/ui?turbo query affected --tasks build# List all packagesturbo query ls# Details for a specific packageturbo query ls web# List only affected packagesturbo query ls --affected
Only run tasks for packages that changed in the PR:
- name: Run affected tasks run: pnpm turbo build test --affected # Turborepo auto-detects GITHUB_BASE_REF in GitHub Actions # Compares PR head to PR base branch # Falls back to running everything if history is unavailable (shallow clone)
Important:--affected needs git history. Use fetch-depth: 0 for full history, or ensure the base ref is available:
- name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # full history for accurate --affected
FROM node:20-alpine AS base# Install dependencies from pruned workspaceFROM base AS installerWORKDIR /appCOPY out/json/ .RUN pnpm install --frozen-lockfile# Build from full sourceFROM base AS builderWORKDIR /appCOPY --from=installer /app/node_modules ./node_modulesCOPY out/full/ .# Remote cache credentialsARG TURBO_TOKENARG TURBO_TEAMENV TURBO_TOKEN=$TURBO_TOKENENV TURBO_TEAM=$TURBO_TEAMRUN pnpm turbo build --filter=web# Production imageFROM base AS runnerWORKDIR /appCOPY --from=builder /app/apps/web/.next/standalone ./EXPOSE 3000CMD ["node", "server.js"]
# Run tasksturbo <task> # run task in all packagesturbo <task> --filter=<pkg> # run in specific packageturbo <task> --affected # run in changed packages onlyturbo <task> --force # force re-run, ignore cacheturbo <task> --dry-run # see what would runturbo <task> --dry-run=json # machine-readable dry runturbo <task> --graph # output task graphturbo <task> --summarize # output run summary with hash detailsturbo <task> --json # stream NDJSON logs (2.9+)turbo <task> --log-file=path.json # write structured logs to file (2.9+)# Cache control (2.9+, replaces deprecated flags)turbo build --cache=local:rw,remote:r # local read+write, remote read-onlyturbo build --cache=remote:rw # remote onlyturbo build --cache=local:r,remote:r # read-only (no cache writes)# Query (stable in 2.9)turbo query # open GraphiQL playgroundturbo query affected # show affected tasksturbo query affected --packages # show affected packagesturbo query affected --tasks build # affected build tasksturbo query ls # list all packagesturbo query ls web # details for web packageturbo query ls --affected # list affected packages# Remote cacheturbo login # authenticate with Vercelturbo link # link to Vercel Remote Cacheturbo logout# Devtoolsturbo devtools # open visual graph explorer# Upgradebunx @turbo/codemod migrate # automated upgrade with codemod
# By name--filter=web--filter=@repo/ui# By path--filter=./apps/web--filter="./apps/*"# Include dependents (packages that depend on the target)--filter=web... # web and its dependents--filter=...web # web and its dependencies# By git changes--filter="[HEAD^1]" # changed in last commit--filter="[main...HEAD]" # changed since branching from main# Affected (smart git detection)--affected # auto-detects base ref
Structure
☐ apps/ for deployable applications
☐ packages/ for shared libraries and config
☐ packageManager field in root package.json
☐ .turbo in .gitignore
☐ Namespace prefix for internal packages (@repo/)
Tasks
☐ build has dependsOn: ["^build"] for libraries
☐ build declares outputs
☐ dev has cache: false, persistent: true
☐ check-types task in every TypeScript package
☐ Package configs (turbo.json per package) for app-specific outputs
Caching
☐ env vars declared in tasks.env
☐ No CI env vars causing cache misses
☐ Remote cache configured for team
☐ Cache artifact signing enabled (production)
CI
☐ fetch-depth: 0 for --affected to work
☐ TURBO_TOKEN and TURBO_TEAM set as CI secrets
☐ --affected for selective builds
☐ --ui=stream for CI-friendly output
1. Env var not declared in turbo.json → add to env[]
2. CI env var included in hash → add to passThroughEnv[] or exclude with TURBO_CI_VENDOR_ENV_KEY
3. outputs not declared → add to outputs[]
4. globalDependencies changed (lockfile, tsconfig) → expected
5. dependency package changed → expected (topological propagation)
Last updated: April 2026. Covers Turborepo 2.9 (March 2026). Key features: 96% faster Time to First Task, turbo query stable, turbo.jsonc, OpenTelemetry (experimental), structured logging, Future Flags for 3.0 migration.