| name | turborepo-monorepo |
| description | Turborepo + pnpm workspace methodology — turbo.json v2 configuration, shared config packages, workspace protocol, pipeline design, Docker builds, and CI optimization. Load when setting up or maintaining a Turborepo monorepo. |
| user-invocable | false |
Turborepo + pnpm Workspace Methodology
1. When to Use
Load this skill when:
- Setting up a new Turborepo monorepo from scratch
- Adding or restructuring workspace packages (apps or libraries)
- Configuring
turbo.json task pipelines (build, lint, test, dev)
- Setting up shared TypeScript or ESLint config packages
- Writing Dockerfiles for monorepo apps (
turbo prune)
- Configuring CI/CD with Turborepo caching (GitHub Actions)
- Diagnosing cache misses, slow builds, or task ordering issues
- Migrating from Turborepo v1 (
pipeline key) to v2 (tasks key)
2. Workspace Structure
<root>/
├── apps/ # Deployable applications and services
│ ├── api/ # NestJS backend
│ ├── web/ # React frontend (buyer-facing)
│ ├── ops-portal/ # React frontend (operators)
│ └── temporal-worker/ # Temporal workflow worker
├── packages/ # Shared libraries and config
│ ├── shared-types/ # Domain types (Just-in-Time .ts exports)
│ ├── db/ # Prisma schema + migrations
│ ├── typescript-config/ # @repo/typescript-config
│ └── eslint-config/ # @repo/eslint-config
├── docker/
├── turbo.json
├── pnpm-workspace.yaml
├── package.json # private: true, packageManager field
└── .node-version
Conventions
| Rule | Detail |
|---|
apps/* | Deployable units only — services, frontends, workers |
packages/* | Shared libraries AND config packages |
@repo/ namespace | Internal package scope (unclaimable on npm) |
| No root build logic | Root scripts are thin turbo wrappers only |
| No nested wildcards | packages/** in pnpm-workspace.yaml causes Turborepo errors |
pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
If config packages are nested under packages/config/:
packages:
- "apps/*"
- "packages/*"
- "packages/config/*"
The packages/config/ directory itself must NOT have a package.json — only its children do.
3. turbo.json v2 Configuration
Schema and top-level keys
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"globalDependencies": ["**/.env*"],
"globalEnv": ["NODE_ENV", "CI"],
"tasks": { }
}
tasks is the v2 key. pipeline is v1 and invalid in v2.
$schema enables IDE autocompletion.
ui: "tui" provides interactive terminal output.
Canonical task graph
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
},
"lint": {},
"check-types": {
"dependsOn": ["^check-types"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
Key concepts
| Concept | Meaning |
|---|
^task | Dependency-first (topological) — run in dependencies before the package itself |
dependsOn: ["^build"] | Build dependencies before building this package |
$TURBO_DEFAULT$ | All git-tracked files (restores default when inputs key is set) |
outputs | Declare artifacts for cache replay — without this, only logs are cached |
cache: false | Never cache this task (dev servers, clean) |
persistent: true | Long-running process — Turborepo won't wait for it to exit |
Variant: type-check before build
Adding "dependsOn": ["^build", "check-types"] to build runs type-check in the same package first. Catches errors earlier but serializes the pipeline.
lint has no dependsOn
Lint reads source directly — omitting dependsOn lets it run in parallel with everything.
inputs precision
Setting inputs opts out of the default hash. $TURBO_DEFAULT$ re-adds git-tracked files. Exclude docs from build hash: "inputs": ["$TURBO_DEFAULT$", "!README.md", "!docs/**"].
4. pnpm Workspace
Internal dependency protocol
All internal packages use workspace:*:
{
"dependencies": {
"@repo/shared-types": "workspace:*",
"@repo/db": "workspace:*"
}
}
workspace:* resolves to the local package. At publish time, pnpm replaces it with the exact version.
saveWorkspaceProtocol: "rolling" (pnpm default) auto-applies workspace:* on pnpm add.
- For publishable packages, use
workspace:^ (semver-compatible replacement at publish).
- Anti-pattern: semver ranges (
"^1.0.0") for internal packages — can accidentally resolve from the npm registry.
Catalog for version centralization
Root package.json:
{
"pnpm": {
"catalog": {
"react": "^18.3.1",
"typescript": "5.8.3",
"zod": "^3.22.0",
"@nestjs/core": "^10.0.0"
}
}
}
Package package.json:
{
"dependencies": {
"zod": "catalog:"
}
}
catalog: resolves from the root catalog. Prevents version drift across workspace packages.
Root package.json
"private": true — prevents accidental publish of the root
"packageManager": "pnpm@10.x.x" — enforces consistent version (Corepack reads this)
- Root scripts are thin wrappers:
"build": "turbo run build" — no build logic here
.npmrc settings
strict-peer-dependencies=true
shamefully-hoist=false
disallow-workspace-cycles=true
5. Shared Config Packages
TypeScript Config
NestJS and Vite/React have fundamentally incompatible TypeScript requirements. They CANNOT share a single base tsconfig.
packages/typescript-config/package.json
{
"name": "@repo/typescript-config",
"private": true,
"version": "0.0.0",
"files": ["*.json"]
}
base.json — shared strict settings
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noUncheckedIndexedAccess": true
}
}
nestjs.json — NestJS apps (CommonJS, decorators)
{
"extends": "./base.json",
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021"],
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.test.*", "**/*.spec.*"]
}
react-vite.json — React/Vite apps (ESNext, Bundler)
{
"extends": "./base.json",
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Why separate bases are mandatory
| Setting | NestJS | Vite/React |
|---|
module | commonjs | ESNext |
moduleResolution | node | Bundler |
experimentalDecorators | true (required by NestJS DI) | false (unused, TC39 decorators preferred) |
emitDecoratorMetadata | true (required by NestJS DI) | false |
jsx | not set | react-jsx |
noEmit | false (tsc compiles) | true (Vite handles emit) |
Attempting to merge these into a single base produces either broken NestJS DI or broken Vite bundling.
Usage in app tsconfig.json
{
"extends": "@repo/typescript-config/nestjs.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
}
}
ESLint Config
packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"private": true,
"version": "0.0.0",
"exports": {
"./base": "./base.js",
"./nestjs": "./nestjs.js",
"./react": "./react.js"
}
}
Apps consume via eslint.config.js:
import baseConfig from "@repo/eslint-config/base";
export default [...baseConfig, { }];
6. Package Conventions
exports field
Prefer exports over main/types in every shared package:
{
"name": "@repo/shared-types",
"private": true,
"exports": {
".": "./src/index.ts",
"./bikes": "./src/bikes/index.ts",
"./orders": "./src/orders/index.ts"
}
}
Benefits: avoids barrel files, enables tree-shaking, provides IDE autocomplete, prevents deep import violations.
Just-in-Time (JIT) vs Compiled packages
| Pattern | When | How |
|---|
JIT (export .ts directly) | Internal-only packages with bundler/compiler consumers | "exports": { ".": "./src/index.ts" } — no build step |
Compiled (export .js + .d.ts) | Packages consumed by tools that don't handle .ts | "exports": { ".": "./dist/index.js" } — needs build task |
JIT is the default for internal monorepo packages. Every internal package must also set "private": true to prevent accidental pnpm publish.
7. Docker Builds
turbo prune --docker
turbo prune api --docker
Generates:
out/json/ — package.json files and pruned lockfile only (for install layer)
out/full/ — full source + manifests (for build layer)
Multi-stage Dockerfile
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Stage 1: Prune the monorepo
FROM base AS pruner
COPY . .
RUN pnpm dlx turbo prune api --docker
# Stage 2: Install dependencies (cached when deps unchanged)
FROM base AS installer
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
# Stage 3: Build
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=api
# Stage 4: Production runner
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=installer /app/node_modules ./node_modules
COPY --from=installer /app/apps/api/dist ./dist
CMD ["node", "dist/main.js"]
Key insight: the install layer (out/json/) only invalidates when API's transitive dependencies change — not when any source file in the monorepo changes.
Remote cache in Docker
Pass TURBO_TOKEN and TURBO_TEAM as build args (ARG + ENV in Dockerfile), then docker build --build-arg TURBO_TOKEN=... --build-arg TURBO_TEAM=....
8. CI / GitHub Actions
With Vercel Remote Cache
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".node-version"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint check-types test build
With local filesystem cache (no Vercel account)
Use actions/cache@v4 with path: .turbo, key ${{ runner.os }}-turbo-${{ github.sha }}, and restore-keys ${{ runner.os }}-turbo-. Same steps otherwise.
CI best practices
fetch-depth: 2 — minimal git history (increase if using --affected with git ranges)
pnpm install --frozen-lockfile — fail on lockfile mismatch, never mutate
concurrency: { group: ${{ github.ref }}, cancel-in-progress: true } — cancel stale PR runs
- Path filters on workflows to skip heavy jobs when unrelated paths change
TURBO_TEAM as a variable (vars.), not a secret (secrets.) — avoids log censoring of team name
9. Future Flags
Add "futureFlags": { ... } to turbo.json to opt into upcoming defaults:
| Flag | Effect |
|---|
affectedUsingTaskInputs | Task-level --affected detection (not just package-level) |
pruneIncludesGlobalFiles | Copies globalDependencies files into turbo prune output — critical for Docker builds where root tsconfig would otherwise be missing |
filterUsingTasks | Task-graph-level --filter resolution |
globalConfiguration | Moves global keys under global: namespace (future v3 prep) |
10. Anti-Patterns
| Anti-pattern | Why it breaks | Fix |
|---|
"pipeline": {} in turbo.json | v1 key, invalid in v2 | Use "tasks": {} |
packages/** in pnpm-workspace.yaml | Nested wildcards cause Turborepo errors | Use packages/* (single level) |
| Root-level build logic | Cannot parallelize or filter per-package | Move to per-package scripts |
Missing cache: false on dev | Caching a persistent process produces stale results | Add "cache": false, "persistent": true |
No outputs on build tasks | Turborepo caches logs but not artifacts | Declare "outputs": ["dist/**"] |
env: ["*"] on tasks | Every env var change busts the cache | List specific vars: "env": ["DATABASE_URL"] |
../ cross-boundary imports | Bypasses package boundaries, fragile paths | Install the dependency via workspace:* |
Barrel files (index.ts re-exporting everything) | Slow compilers/bundlers, defeats tree-shaking | Use exports field with granular entry points |
| Semver ranges for internal packages | Can resolve from npm registry instead of workspace | Use workspace:* protocol |
| Single tsconfig base for NestJS + Vite | module/moduleResolution are fundamentally incompatible | Separate nestjs.json and react-vite.json bases |
Missing turbo prune in Docker | Any lockfile change rebuilds all apps | Use turbo prune <app> --docker |
| Circular workspace dependencies | Build ordering breaks, pnpm warns | Extract shared code to a separate package; enable disallow-workspace-cycles |
11. Cross-References
| Skill / Rule | Relationship |
|---|
toolchain-isolation | pnpm and fnm usage discipline |
cross-platform-portability | Portable scripts within the monorepo |
service-governance | Per-service structure within apps/ |
portability | No hardcoded paths in turbo.json or package.json |
random-ports | Port assignment for dev servers |
yaml-config-preference | Config format for non-ecosystem files |
documentation-first-code | README per package |