How to Set Up a pnpm + Turborepo Monorepo from Scratch
Correctly bootstrap a pnpm + Turborepo monorepo from scratch with real task caching — v2 "tasks" key, not the deprecated "pipeline" most tutorials use.
By Ethan
1,336 words · 7 min read
Most guides for this setup are wrong. Not subtly wrong — they use a turbo.json key that was deprecated in Turborepo v2, and your build either silently falls back or throws a warning you have to know to look for. This guide uses the current defaults: pnpm 10+, Turborepo 2.x, Node 20+.
Who this is for
A mid-level dev who has worked in a monorepo but never set one up from scratch. You have Node and pnpm installed. You want real task caching across packages, not merely a shared node_modules.
If you’re still deciding between pnpm and npm, see pnpm vs npm — what actually changes when you switch first.
If you already have an existing repo you want to add Turborepo to, the adding to an existing monorepo guide covers the incremental path. This guide starts from zero.
Step 1: Prerequisites
You need Node 20+ and pnpm 10+. Check what you have:
node --version # should be v20 or higher
pnpm --version # should be 10.x or higher
To install or upgrade pnpm 10:
npm install -g pnpm@10
If you’re on pnpm 11, note that standalone pnpm 11 requires Node 22+. The tutorial targets pnpm 10 as the minimum — everything here works on 11 too, but don’t use pnpm 11 with Node 20.
Failure mode: If pnpm --version shows 8.x or 9.x, upgrade. Earlier pnpm versions have different defaults for linkWorkspacePackages that can cause unexpected hoisting behavior when Turborepo is involved.
Step 2: Scaffold the workspace
mkdir my-monorepo && cd my-monorepo
Create pnpm-workspace.yaml at the root:
packages:
- "apps/*"
- "packages/*"
This file is not optional. Without it, pnpm treats every directory as an independent package and workspace protocol links (workspace:*) will fail with resolution errors at install time.
Create the root package.json:
{
"name": "my-monorepo",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "latest"
}
}
The packageManager field is required, not optional. Corepack uses it for version pinning, and Turborepo’s own docs call it out as a requirement. Without it, you get version drift across dev environments the moment a second person clones the repo.
Now install:
pnpm install
Failure mode: If you get ERR_PNPM_WORKSPACE_NOT_FOUND or similar, check that pnpm-workspace.yaml exists at the repo root, not inside a subdirectory.
Step 3: Add Turborepo
Create turbo.json at the root:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
The key is "tasks", not "pipeline". Every tutorial from 2022–2023 uses pipeline — that was the v1 key. Turborepo v2 renamed it to tasks. If you paste a pre-2024 config, you’ll get a deprecation warning at minimum; on some versions the build silently skips caching entirely.
Add .turbo to .gitignore:
.turbo
Failure mode: If you see ERROR: turbo.json references a key "pipeline" or nothing gets cached, you have a v1 config. Replace "pipeline" with "tasks" and rerun.
Step 4: Create two packages and wire them up
Set up a utility library and an app that depends on it:
mkdir -p packages/utils/src apps/web/src
packages/utils/package.json:
{
"name": "@acme/utils",
"version": "0.0.1",
"private": true,
"main": "./dist/index.js",
"exports": {
".": {
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
packages/utils/src/index.ts:
export function greet(name: string): string {
return `Hello, ${name}!`;
}
packages/utils/tsconfig.json:
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"module": "commonjs",
"target": "es2020",
"strict": true
},
"include": ["src"]
}
apps/web/package.json:
{
"name": "@acme/web",
"version": "0.0.1",
"private": true,
"dependencies": {
"@acme/utils": "workspace:*"
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
apps/web/src/index.ts:
import { greet } from "@acme/utils";
console.log(greet("world"));
apps/web/tsconfig.json:
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "es2020",
"strict": true
},
"references": [{ "path": "../../packages/utils" }],
"include": ["src"]
}
Now install to wire up the workspace:* link:
pnpm install
pnpm creates a symlink from node_modules/@acme/utils to packages/utils. The workspace:* protocol means “use whatever local version exists.” On pnpm publish, it converts to a real semver range automatically.
Failure mode: If apps/web can’t resolve @acme/utils at build time, check that pnpm install ran after you added the dependency. The symlink is created at install time, not at repo init time.
Failure mode (phantom dependencies): pnpm does not hoist by default. If your app tries to import a package that’s in @acme/utils’s node_modules but not declared in apps/web/package.json, it will fail. This is intentional — pnpm enforces clean dependency declarations. Fix it by adding the missing package to the correct package.json.
Step 5: Run your first turbo build and inspect the cache
pnpm turbo build
The first run builds everything. You’ll see Turborepo identify the dependency graph, run @acme/utils build first (because apps/web depends on it via dependsOn: ["^build"]), then build @acme/web.
Run it again immediately:
pnpm turbo build
Output:
• Packages in scope: @acme/utils, @acme/web
• Running build in 2 packages
• Remote caching disabled
@acme/utils:build: cache hit, replaying logs dc3f1a2...
@acme/web:build: cache hit, replaying logs 9b4e8c1...
Tasks: 2 successful, 2 total
Cached: 2 cached, 2 total
Time: 87ms >>> FULL TURBO
“FULL TURBO” means every task restored from local cache without re-execution. On a real project with 10–30 packages, this difference is measured in minutes, not milliseconds.
To debug a cache miss:
pnpm turbo build --summarize
This writes a JSON file to .turbo/runs/ with the full input hash breakdown — which files contributed to the cache key, which env vars were included, and where the hash differs from the previous run. It’s verbose but it’s the authoritative answer when cache behavior is unexpected.
Step 6: Common gotchas
pipeline vs tasks: Already covered, but worth repeating. If you’re adapting an existing turbo.json, search-and-replace "pipeline" with "tasks". The configuration reference is the canonical source for current v2 syntax — the v1→v2 migration guide URL returns 404 as of 2026.
Missing pnpm-workspace.yaml: The most common setup error. The file must exist at the repo root before you run pnpm install. If you add it after, re-run pnpm install to rebuild the symlinks.
Missing outputs: Without "outputs": ["dist/**"] on a build task, Turborepo runs the task but doesn’t save any files to the cache. The next run will hit a cache hash match but restore nothing. Your downstream packages will fail because dist/ is empty. Always declare outputs for tasks that produce files.
"^build" vs "build" in dependsOn: "^build" means “run build in all packages this package depends on.” "build" (without ^) means “run build in the same package first.” For most build tasks you want "^build". Using "build" by mistake causes builds to run in the wrong order when you have cross-package dependencies.
cache: false for dev tasks: The dev task config above uses "cache": false and "persistent": true. Without cache: false, Turborepo may attempt to cache a long-running process output, which doesn’t make sense. Without persistent: true, Turborepo doesn’t know the task is meant to run indefinitely and may not handle the process lifecycle correctly during turbo dev.
pnpm hoisting strictness: pnpm’s strict hoisting is a feature, not a bug — it forces you to declare every dependency your code actually uses. But it breaks packages that were written assuming npm-style flat hoisting. If you see Cannot find module 'X' for a package you didn’t explicitly install, add it to the package.json of the package that uses it.
References
- pnpm workspaces
- pnpm-workspace.yaml reference
- Turborepo: getting started
- Turborepo: configuring tasks
- Turborepo: caching
- turbo.json v2 configuration reference
- Creating internal packages
Once your monorepo is running, setting up Claude Code for a monorepo covers how to scope AI assistance per package using CLAUDE.md hierarchy and worktrees.