Monorepo with Turborepo: A Production-Ready Setup from Scratch
Stop duplicating code across repos. This deep dive walks through a complete production-ready Turborepo monorepo setup with pnpm workspaces, shared packages, pipeline configuration, local and remote caching, CI/CD integration, and the pitfalls that trip up real teams.
If your codebase has more than one application sharing any amount of logic, you've already encountered the pain: duplicated utilities, mismatched TypeScript configs, dependency hell across separate repos, and the classic "did you publish the shared package?" question before every deploy. A monorepo solves this — and Turborepo makes running one in production not just feasible, but genuinely fast.
This guide walks through a complete, production-ready Turborepo setup from scratch — the kind you'd actually use for a real product, not a toy demo.
Why Monorepos? The Big Picture
A monorepo is a single version-controlled repository that houses multiple projects — apps, packages, services — with a clear internal dependency graph. Companies like Google, Meta, and Vercel manage massive monorepos for good reason:
- Atomic changes — update a shared component and all its consumers in one PR
- Unified tooling — one ESLint config, one TypeScript setup, one CI pipeline
- Simplified dependency management — no publishing packages to npm just to use them internally
- Better visibility — all code in one place, easier to audit and refactor
The trade-off? Monorepos get painfully slow at scale if you naively run every task on every commit. That's exactly the problem Turborepo was built to solve — with a smart task execution engine and aggressive caching.
Project Structure We're Building
Before diving into config, here's the target structure:
my-monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ └── api/ # Node.js/Express backend
├── packages/
│ ├── ui/ # Shared React component library
│ ├── utils/ # Shared utility functions
│ └── config/ # Shared configs (ESLint, TypeScript, Tailwind)
├── turbo.json
└── package.json
Two apps consuming three internal packages. Simple enough to understand, realistic enough to transfer directly to production.
Step 1: Workspace Setup with pnpm
Turborepo works with npm, yarn, and pnpm — but pnpm is the recommended choice. Its workspace protocol is explicit and performant, and its strict dependency isolation catches bugs that npm workspaces silently miss.
Initialize the root:
mkdir my-monorepo && cd my-monorepo
pnpm init
Create pnpm-workspace.yaml at the root to declare your workspace packages:
packages:
- 'apps/*'
- 'packages/*'
Update your root package.json:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"turbo": "2.1.3",
"prettier": "^3.0.0"
},
"engines": {
"node": ">=18",
"pnpm": ">=9"
}
}
Install Turborepo at the workspace root (the -w flag targets the root):
pnpm add -D turbo -w
Create your directory structure:
mkdir -p apps/web apps/api packages/ui packages/utils packages/config
Step 2: Shared Packages
packages/config — Base TypeScript and ESLint Configs
The unsung hero of any monorepo. Instead of maintaining separate configs per project, you define one canonical set and extend it everywhere.
packages/config/package.json:
{
"name": "@repo/config",
"version": "0.0.1",
"private": true,
"files": ["eslint.js", "typescript/"]
}
packages/config/typescript/base.json:
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"exclude": ["node_modules"]
}
Now in any app or package, extend it:
{
"extends": "@repo/config/typescript/base.json",
"include": ["src"],
"compilerOptions": {
"outDir": "dist"
}
}
packages/utils — Shared Utilities
packages/utils/package.json:
{
"name": "@repo/utils",
"version": "0.0.1",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src/"
},
"devDependencies": {
"@repo/config": "workspace:*",
"typescript": "^5.0.0"
}
}
The workspace:* protocol tells pnpm this is an internal dependency. When you run pnpm install, it creates a symlink directly to the local package — no publishing to npm required, no version drift.
packages/ui — Shared React Components
packages/ui/package.json:
{
"name": "@repo/ui",
"version": "0.0.1",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.tsx --format cjs,esm --dts",
"dev": "tsup src/index.tsx --format cjs,esm --dts --watch",
"lint": "eslint src/"
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@repo/config": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
}
Note the dual exports map — this ensures compatibility with both CJS and ESM consumers without requiring any extra configuration from the consumer side.
Step 3: Turborepo Pipeline Configuration
This is the heart of the setup. turbo.json defines your task dependency graph — what runs before what, what files invalidate the cache, what env vars affect the output, and which artifacts to store.
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", ".eslintrc.*", "eslint.config.*"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "vitest.config.*"],
"outputs": ["coverage/**"]
},
"type-check": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"]
}
}
}
Breaking down the critical parts:
dependsOn: ["^build"]— The caret (^) means "build all upstream dependencies first." Ifwebdepends on@repo/ui, Turborepo will builduibefore startingweb. This is the dependency graph made explicit.inputs— The files that, when changed, invalidate the cache. Be specific: only list files that actually affect the output. Broader globs mean more cache misses.outputs— Files to cache and restore on a cache hit. The negation!.next/cache/**skips Next.js's internal build cache, which is large and managed separately.env— Environment variables that, when changed, bust the cache. Critical for builds that inline env vars (likeNEXT_PUBLIC_*).cache: false— Dev servers are never cached. They're long-running processes with live output.persistent: true— Signals to Turborepo that this task won't exit, enabling proper process management.
Step 4: Caching — Where the Magic Happens
Local Cache
Turborepo's local cache lives at ./node_modules/.cache/turbo (or ~/.turbo for global cache). On first run, every task executes normally. On subsequent runs, if the inputs haven't changed, Turborepo replays cached output in milliseconds — including restoring all output files from the outputs declaration.
Run turbo build twice and compare:
# First run
Tasks: 5 successful, 5 total
Cached: 0 cached, 5 total
Time: 3m 12s
# Second run
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 312ms >>> FULL TURBO
"FULL TURBO" means every task was served from cache. A 3-minute build now takes under a second.
Remote Cache
Local cache only helps one developer. Remote cache helps your entire team and every CI run. Turborepo can push and pull cache artifacts from a central cache server, so a build done by one developer (or by CI) is instantly available to everyone else.
The official option is Vercel Remote Cache, which is free for personal use. Connect your project with two commands:
npx turbo login
npx turbo link
For self-hosted setups, ducktape/turborepo-remote-cache is a popular open-source server that speaks the same protocol:
docker run -p 3000:3000 \
-e TURBO_TOKEN=your-secret-token \
-e STORAGE_PROVIDER=local \
ducktape/turborepo-remote-cache
Configure the remote in CI via environment variables — no changes to turbo.json needed:
TURBO_TEAM=your-team
TURBO_TOKEN=your-secret-token
TURBO_API=https://your-cache-server.com
With remote cache enabled, a developer pulling a feature branch already built by CI gets full cache hits locally. Zero rebuild time.
Step 5: CI/CD Integration
Here's a production-grade GitHub Actions workflow:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build lint test type-check
Key points in this workflow:
fetch-depth: 2— Turborepo uses git history to determine which packages changed with--filter. Shallow clones with depth 1 break this feature entirely.- TURBO_TOKEN and TURBO_TEAM — Setting these environment variables automatically enables remote cache. No extra configuration needed.
- Frozen lockfile — Prevents accidental lockfile mutation in CI. A changed lockfile in CI is always a red flag.
- Single command —
turbo build lint test type-checkruns all tasks in maximum parallelism where dependencies allow, sequentially where they require it. Turborepo orchestrates the entire graph.
For targeted deployments, use --filter:
# Deploy only the web app
turbo build --filter=web
# Build only what changed since last merge
turbo build --filter=[HEAD^1]
Step 6: Common Pitfalls
1. Missing outputs in turbo.json
If you don't declare outputs, Turborepo caches the task result (exit code, stdout/stderr) but won't restore the actual build artifacts. Your next run shows a cache hit, but the dist/ folder is empty.
Fix: Always declare
outputsfor any task that writes files to disk.
2. Environment Variables Missing from Cache Keys
If your build reads environment variables (e.g., NEXT_PUBLIC_API_URL), those values must appear in the env array of the task config. Otherwise you risk serving a cached build with the wrong env vars baked in — a silent, hard-to-debug production bug.
3. Not Marking Internal Packages as Private
Every internal package must have "private": true in its package.json. Without it, running pnpm publish -r at the workspace root could accidentally publish your internal packages to the public npm registry.
4. Not Pinning the Turbo Version
Turborepo evolves quickly. Pin the exact version in your root package.json and update deliberately with a dedicated PR so the team can review the changelog:
"devDependencies": {
"turbo": "2.1.3"
}
5. Circular Package Dependencies
Turborepo detects circular task dependencies and errors out. But circular package dependencies — @repo/ui importing from @repo/utils which imports from @repo/ui — can slip through and cause subtle runtime issues. Design your dependency graph intentionally: shared packages should never depend on apps, and the dependency flow should be acyclic.
6. Running Tasks Outside the Root
Running pnpm dev from inside apps/web/ bypasses Turborepo entirely — no caching, no dependency orchestration. Always run tasks from the workspace root, or use turbo dev --filter=web to target a specific app while keeping Turborepo in control.
Debugging Cache Misses
When a task keeps missing cache unexpectedly, use the dry-run JSON mode to inspect exactly what Turborepo sees:
turbo run build --dry=json
This outputs a full JSON graph of every task, its resolved inputs, and its cache status. Compare the hashOfExternalDependencies and globalHashSummary between runs to find what changed. The --summarize flag gives you a detailed post-run report written to .turbo/runs/.
The Real Win: Developer Experience
After this setup is in place, the day-to-day experience changes fundamentally. A new developer clones the repo, runs pnpm install && pnpm dev, and everything works — no published packages to track down, no version mismatches, no manual build steps before the dev server starts. TypeScript gives them full cross-package type safety, and go-to-definition in their editor jumps to actual source code, not compiled output.
Your CI pipeline drops from 8 minutes to under a minute after the first few runs populate remote cache. Refactoring a shared utility and seeing all consumers update with type-safe changes stops feeling like a miracle and starts feeling like the baseline.
That's the promise of a well-configured monorepo. Turborepo doesn't just make it possible — it makes it fast enough to actually use in production without compromising on developer experience.
Summary
- Use pnpm workspaces with
workspace:*for zero-friction internal dependencies - Structure
turbo.jsonwith carefuldependsOn,inputs,outputs, andenvdeclarations - Enable remote cache early — it pays dividends immediately across the whole team and CI
- Keep shared packages focused: config, utils, UI — not a dumping ground for anything miscellaneous
- Always declare
"private": trueon internal packages to prevent accidental publishing - Use
--filterfor targeted builds and deployments; use--dry=jsonfor debugging - Pin your Turbo version and update it deliberately
The initial setup takes an afternoon. The productivity payoff lasts the entire lifetime of the project.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!