Published on

Turborepo Monorepo — Scaling to 100+ Packages With Shared Config and Fast Builds

Authors

Introduction

Polyrepos—separate repositories for each package—don't scale. You can't share code without npm publishing, you can't refactor cross-package without coordinating PRs, you can't test changes atomically.

Monorepos solve this. Turborepo makes monorepos fast: intelligent task scheduling, remote caching, and dependency graph pruning ensure you rebuild only what changed.

This guide builds a production monorepo scaling from 10 to 100+ packages.

Workspace Setup With npm/pnpm Workspaces

Modern package managers support workspaces natively. No specialized tooling required.

{
  "name": "monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*",
    "tools/*"
  ]
}

Directory structure:

monorepo/
├── package.json
├── pnpm-workspace.yaml          # pnpm uses YAML instead of package.json
├── packages/                     # Shared libraries
│   ├── ui/                      # React components
│   │   ├── package.json
│   │   ├── src/
│   │   └── tsconfig.json
│   ├── config/                  # Shared TypeScript configs
│   ├── utils/                   # Utility functions
│   └── database/                # Prisma client
├── apps/                         # Applications
│   ├── web/                     # Next.js web app
│   ├── api/                     # Express API
│   ├── admin/                   # Admin dashboard
│   └── mobile-api/              # Mobile backend
├── tools/                        # Build/deploy tools
│   ├── cli/
│   └── scripts/
└── turbo.json

pnpm workspace config (recommended over npm):

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'

# pnpm hoists dependencies: faster installs, better discoverability
hoist-pattern:
  - '*'
  - '@types/*'

public-hoist-pattern:
  - '@types/*'
  - 'lodash'

turbo.json Pipeline Configuration

Turborepo orchestrates build tasks across workspaces using a dependency graph.

{
  "$schema": "https://turborepo.org/schema.json",
  "version": "1",
  "extends": ["//"],

  "globalDependencies": [
    ".env.local",
    ".nvmrc"
  ],

  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"],
      "cache": true
    },

    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"],
      "cache": true,
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**"]
    },

    "lint": {
      "outputs": [],
      "cache": true,
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "*.json"]
    },

    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    },

    "dev": {
      "cache": false,
      "persistent": true,
      "outputs": ["dist/**"]
    },

    "deploy": {
      "dependsOn": ["^build"],
      "outputs": [],
      "cache": false
    },

    "api#dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["database#migrate"]
    },

    "database#migrate": {
      "cache": false,
      "persistent": true
    }
  },

  "remoteCache": {
    "enabled": true
  }
}

Task naming conventions:

  • package#task: run task only in package
  • ^task: run task in dependencies first
  • No prefix: run task in all packages

Remote Caching With Vercel

Turborepo's remote cache speeds CI/CD by sharing build artifacts across machines.

# Authenticate with Vercel
turbo login

# Link to team
turbo link --team=your-team

Cache outputs go to Vercel by default:

// turbo.json
{
  "remoteCache": {
    "enabled": true
  }
}

Self-hosted remote cache:

# docker-compose.yml
version: '3.8'

services:
  turbo-cache:
    image: turbo-remote-cache:latest
    ports:
      - '3000:3000'
    environment:
      DATABASE_URL: postgresql://user:pass@postgres:5432/turbo_cache
    depends_on:
      - postgres

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: turbo_cache
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

GitHub Actions with remote cache:

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install pnpm
        uses: pnpm/action-setup@v2

      - name: Install
        run: pnpm install

      - name: Build and test
        run: pnpm turbo run build test lint

Dependency Graph and Pruning

Turborepo builds dependency graphs to optimize builds.

# See visual graph
turbo --graph

# Build only affected by changes on PR
turbo run build --filter="...[origin/main]"

# Build package and affected dependents
turbo run build --filter="...{packages/ui}[HEAD^]"

Pruning for Docker (build only needed packages):

# Dockerfile
FROM node:18 AS pruner

WORKDIR /app
COPY . .

RUN npm install -g turbo
RUN turbo prune --scope=apps/web --docker

FROM node:18 AS builder

WORKDIR /app
COPY --from=pruner /app/out/json .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml

RUN npm install -g pnpm
RUN pnpm install

COPY --from=pruner /app/out/full .

RUN pnpm turbo run build --filter=web

FROM node:18-alpine

WORKDIR /app
COPY --from=builder /app/apps/web/dist .

EXPOSE 3000
CMD ["node", "server.js"]

Shared TypeScript Configs

Central config eliminates duplication:

// packages/config/tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "ESNext",
    "strict": true,
    "declaration": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@monorepo/ui": ["../ui/src"],
      "@monorepo/utils": ["../utils/src"],
      "@monorepo/database": ["../database/src"]
    }
  }
}

Per-package extends base:

// apps/web/tsconfig.json
{
  "extends": "../../packages/config/tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "paths": {
      "@/*": ["./src/*"],
      "@monorepo/*": ["../../packages/*/src"]
    }
  },
  "include": ["src"]
}

Internal Packages Pattern

Shared libraries as internal packages with versioning.

// packages/ui/package.json
{
  "name": "@monorepo/ui",
  "version": "1.0.0",
  "private": true,
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/Button.d.ts",
      "default": "./dist/Button.js"
    }
  },
  "dependencies": {
    "@monorepo/utils": "workspace:*"
  },
  "devDependencies": {
    "react": "^18.0.0"
  }
}

Usage in apps:

// apps/web/package.json
{
  "dependencies": {
    "@monorepo/ui": "workspace:*",
    "@monorepo/database": "workspace:*"
  }
}

TypeScript paths for direct imports:

// apps/web/src/App.tsx
import { Button } from '@monorepo/ui';
import { Button as ButtonComponent } from '../../packages/ui/src/Button';

// Both work; first is cleaner with path aliases

Affected Packages on PR

Only test/build what changed:

# Changed files vs origin/main
turbo run build --filter="...[origin/main]"

# Specific package and dependents
turbo run test --filter="...{packages/database}[HEAD~1]"

# List affected without running
turbo run --dry=json --filter="...[origin/main]" | jq '.tasks[].package'

GitHub Actions for affected:

# .github/workflows/affected.yml
name: Test Affected

on: [pull_request]

jobs:
  affected:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Full history for git diff

      - uses: pnpm/action-setup@v2

      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'

      - run: pnpm install

      - run: pnpm turbo run build test --filter="...[origin/${{ github.base_ref }}]"

Migrating From Polyrepo

Converting separate repos to monorepo:

# 1. Create monorepo structure
mkdir monorepo && cd monorepo
git init
pnpm init -y

# 2. Add workspaces
pnpm install -w
mkdir -p packages apps

# 3. Migrate first package
cd packages
git subtree add --prefix packages/utils \
  https://github.com/org/repo-utils.git main

# 4. Update import paths in migrated package
# From: import { foo } from '../../utils/src'
# To: import { foo } from '@monorepo/utils'

# 5. Setup Turborepo
npm install -g turbo
turbo init

# 6. Configure turbo.json
# 7. Update CI/CD to use turbo

# Repeat for each package

Parallel migration using git subtree:

#!/bin/bash
# scripts/migrate-repos.sh

REPOS=(
  "repo-utils:packages/utils"
  "repo-api:apps/api"
  "repo-ui:packages/ui"
  "repo-web:apps/web"
)

for repo in "${REPOS[@]}"; do
  IFS=':' read -r source_repo target_path <<< "$repo"

  git subtree add \
    --prefix="$target_path" \
    "https://github.com/org/$source_repo.git" \
    main --squash

  echo "Migrated $source_repo to $target_path"
done

echo "Migration complete. Run 'pnpm install && turbo run build' to verify"

Monorepo Best Practices Checklist

## Monorepo Scaling Checklist

### Structure
- [ ] Clear separation: apps/ (deployed), packages/ (libraries), tools/ (build)
- [ ] One concept per package (single responsibility)
- [ ] Shared configs in dedicated packages/config package
- [ ] README in each package documenting purpose and dependencies

### Build System
- [ ] turbo.json defines all tasks with correct dependsOn
- [ ] Outputs specified correctly for caching
- [ ] Remote caching enabled (Vercel or self-hosted)
- [ ] CI uses affected filters: --filter="...[origin/main]"
- [ ] Local turbo cache cleared in CI (--no-cache)

### Dependencies
- [ ] Internal packages use workspace:* protocol
- [ ] No circular dependencies (turbo validates)
- [ ] Path aliases configured in tsconfig.base.json
- [ ] External dependencies pinned in root package-lock
- [ ] pnpm workspaces with hoist-pattern configured

### Testing and Linting
- [ ] Test runs respect workspace boundaries
- [ ] Linting shares eslint config from packages/config
- [ ] Type checking runs on full repo
- [ ] Coverage reports aggregated

### Releasing
- [ ] Versioning strategy decided (semver, independent per package)
- [ ] Changelog auto-generated from commits
- [ ] Only changed packages published (if using npm)
- [ ] Git tags created for releases

Conclusion

Monorepos with Turborepo scale from 10 packages to 100+. The key is proper configuration: clear workspace structure, task pipeline definition, shared configs, and remote caching.

Start with one monorepo. Add structure as it grows. Use Turborepo's intelligent scheduling to keep builds fast even as complexity grows.

Stop maintaining 10 separate CI/CD pipelines. Build once, cache forever.