Turborepo Setup Guide: Monorepo Caching, Pipelines and Remote Cache
Set up Turborepo from scratch — pipelines, local caching, remote cache with Vercel, and real-world monorepo patterns that actually speed up CI.
Why Turborepo Over Plain npm Workspaces
npm workspaces (and yarn/pnpm equivalents) handle the dependency graph. Turborepo handles *execution*. That distinction sounds small until you're waiting 8 minutes on CI for a change to one package that touches maybe 200 lines of TypeScript.
Plain workspaces run tasks in topological order, sure, but they re-run everything, every time. Turborepo adds two things on top: a task pipeline that understands which outputs are safe to cache, and a content-hash-based cache that skips work when neither inputs nor dependencies have changed. The result is that a cold first run might take 4 minutes, but a warm run after touching only your UI package takes 18 seconds.
Turborepo v2 (released in 2024) rewrote the core engine in Rust and dropped the legacy JS scheduler entirely. You don't need to care about that implementation detail except to know that scheduling overhead at 50+ packages is now negligible — and that upgrading from v1 is just a version bump, no API changes.
Honestly, the biggest argument for Turborepo over raw scripts is that the configuration is declarative and lives in version control. You're not maintaining a bespoke shell script graveyard. One turbo.json file describes what depends on what, and Turborepo figures out the rest.
That said, Turborepo isn't magic. If your packages have circular dependencies, tight coupling, or tasks with non-deterministic outputs (looking at you, date-stamped build artifacts), caching will either break or give you false hits. Fix those first, then add Turborepo.
Initial Setup: Workspace + turbo.json
Start from an npm workspaces root. If you're greenfield, the official scaffold gets you there fast:
npx create-turbo@latest my-monorepo
cd my-monorepoThat gives you a packages/ directory with a shared ui and utils package, plus an apps/web Next.js app. The root package.json declares the workspace globs:
{
"name": "my-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"devDependencies": {
"turbo": "^2.0.0"
}
}Now the important part — turbo.json at the repo root. This is where you define tasks and their cache contracts. A minimal setup for a Next.js app with a shared component library looks like this:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"dependsOn": ["^lint"],
"inputs": ["src/**", "*.ts", "*.tsx", ".eslintrc*"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "**/*.test.ts", "**/*.test.tsx"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}The ^build syntax means "run build in all upstream dependencies first." Worth noting: dev has cache: false because dev servers have side effects and running state — caching a watch process makes no sense. Everything else is fair game.
Understanding the Cache: Inputs, Outputs, and Hashes
Turborepo computes a hash for each task from three things: the task's inputs glob patterns (file contents + mtimes), the package's dependency graph, and the task's name. If that hash exists in the cache — local or remote — Turborepo replays the cached stdout/stderr and restores the output files. Zero work done.
The inputs field is critical. Get it wrong and you either bust the cache on every run (too broad) or ship stale builds (too narrow). A safe default for most TypeScript packages is ["src/**", "package.json", "tsconfig.json"]. If you have generated files that change based on env vars, exclude them explicitly or accept that those tasks won't cache.
Local cache lives at node_modules/.cache/turbo by default. On a developer machine that's fine — you're often the only person building. On CI, the local cache is cold on every run unless you persist it between jobs, which most CI systems let you do by caching the .turbo/ directory. That alone can cut CI time by 40-60% on repeat runs with small diffs.
In practice, the log replay behavior is one of the nicest quality-of-life features. When Turbo skips a task it prints cache hit, replaying output and shows you the exact output from the last run. Your logs look identical whether the work was done live or restored from cache. No confusing blank output to debug.
Remote Cache: Share Hits Across Machines
Local cache only helps you. Remote cache shares hits across your whole team and every CI runner. The idea is simple: task outputs get pushed to a blob store keyed by the same content hash, and any machine that computes the same hash downloads the artifacts instead of rebuilding.
Vercel's remote cache is free for personal accounts and ships zero-config with Turborepo. Log in with the CLI and you're done:
# Authenticate once per machine
npx turbo login
# Link this repo to your Vercel team's remote cache
npx turbo link
# Now build — cache hits go up and down automatically
npx turbo buildFor self-hosted setups you have options. Turborepo's remote cache API is an open spec — ducktape, turbogrid, and turborepo-remote-cache (a popular open-source Go implementation) all implement it. Point Turbo at your custom endpoint via environment variables:
# In CI env vars or .env.local (never commit this)
TURBO_API="https://your-cache.example.com"
TURBO_TOKEN="your-secret-token"
TURBO_TEAM="your-team-id"Quick aside: the Vercel remote cache stores artifacts for 7 days by default. If your team rebases rarely and CI is the primary consumer, that window is plenty. For local developer machines that might sit idle for two weeks, you'll occasionally see a cold rebuild — that's expected behavior, not a bug.
One more thing — remote cache hits restore *output files*, not just logs. So a dist/ or .next/ folder is fully reconstructed from the blob. This means downstream tasks that depend on those outputs also skip, compounding the savings.
Pipeline Parallelism and Task Filtering
Turborepo runs tasks in parallel by default, constrained by the dependency graph. If app-web depends on packages/ui, Turborepo builds ui first, then runs app-web's build in parallel with any other top-level packages that don't share that dep. On an 8-core machine this matters a lot.
You can control concurrency with --concurrency. The default is the number of logical CPUs. On a beefy CI runner (--concurrency=8) you want that. On a dev laptop where you're also running Chrome, VS Code, and a Docker daemon, --concurrency=3 is more comfortable:
# Run builds across all packages with 4 parallel workers
npx turbo build --concurrency=4
# Only build and test packages affected by changes since main
npx turbo build test --filter=...[main]
# Run a specific package and its dependencies
npx turbo build --filter=@repo/web...The --filter flag is where Turborepo gets genuinely powerful for large repos. --filter=...[main] uses git history to find which packages have changed since branching from main, then builds only those packages and anything that depends on them. A PR that touches only your icon package won't rebuild your six other apps. Not even a little.
If you're shipping a component library anything like what you'd find at Empire UI, this kind of scoped build is indispensable — you don't want a docs-site rebuild every time you fix a bug in one animation utility. The [main] filter runs cleanly in most CI environments as long as you fetch enough git history (git fetch --depth=20 is usually enough).
CI Integration: GitHub Actions Example
Here's a full GitHub Actions workflow that gives you remote cache hits on every PR run and caches node_modules between jobs. This pattern works with Vercel's remote cache — swap the env vars for a self-hosted endpoint if needed.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Fetch enough history for --filter=[main] to work
fetch-depth: 20
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build affected packages
run: npx turbo build --filter=...[main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- name: Lint affected packages
run: npx turbo lint --filter=...[main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- name: Test affected packages
run: npx turbo test --filter=...[main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}Store TURBO_TOKEN and TURBO_TEAM as GitHub Actions secrets, not in your repo. The npm ci + Node cache combo keeps install times under 30 seconds on warm runners. On a monorepo with 12 packages, this workflow gets most PRs through build + lint + test in under 90 seconds once the remote cache is warm.
Look, the fetch depth thing trips people up constantly. Turborepo's git-based filtering needs enough history to find the merge base between your branch and main. fetch-depth: 20 covers 99% of typical development flows. If you do long-lived feature branches (more than 50 commits), bump it to 50.
Worth noting: if TURBO_TOKEN is missing or invalid, Turborepo silently falls back to local-only mode. It won't error, which is both a feature (CI doesn't break) and a footgun (you don't notice when remote cache stops working). Add a step that runs npx turbo whoami to verify the auth is live.
Common Gotchas and How to Fix Them
Stale cache hits on environment-sensitive tasks. If your build embeds process.env.NODE_ENV or API URLs, those need to be part of the cache key. Add them to env in your task config:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
}
}
}Missing outputs causing downstream failures. If a package's build task doesn't list its output directory in outputs, Turborepo won't restore those files on a cache hit. The task shows as cached but the files aren't there, and whatever depends on them breaks. Always be explicit: .next/**, dist/**, build/** — whatever your bundler produces.
Circular dependencies between packages. Turborepo will throw a clear error, but the fix isn't always obvious. Usually it means you need to extract shared logic into a third package that both can import without depending on each other. This is good architecture anyway — it's just Turborepo surfacing it. See the design system documentation guide for patterns on structuring shared packages.
`dev` tasks blocking other tasks. If you define dev without persistent: true and cache: false, Turborepo might try to run it as a dependency and hang indefinitely. Always mark long-running watch processes correctly — this tripped up a lot of early Turborepo users before v2 made the error message clearer.
What's the single most common mistake? Forgetting to run turbo build instead of npm run build in each package. If you run build scripts directly without going through Turbo, you bypass the cache entirely and your cache hit rate tanks. Wire Turborepo into every CI step and every dev's local npm run scripts from day one.
FAQ
Yes — Turborepo is workspace-manager agnostic. You configure it the same way whether you're using npm, pnpm, or yarn workspaces; just make sure your root package.json has the correct workspaces field for your package manager.
It's free for personal Vercel accounts and has generous limits for hobby use. Team plans are paid — check Vercel's pricing page for current artifact storage limits, since they've changed these a few times since 2024.
Run any Turborepo command with --force — for example npx turbo build --force. This skips reading from cache but still writes new results to it, so the next run can benefit from the fresh cache.
Absolutely. The .next/** output glob captures everything the Next.js build produces, including API routes. Just make sure any env vars embedded during build (like NEXT_PUBLIC_*) are listed in the task's env array so cache keys reflect environment differences.