Bỏ qua để vào nội dung chính
Monorepo with Turborepo: A Production-Ready Setup from Scratch

Monorepo with Turborepo: A Production-Ready Setup from Scratch

Bởi Ethan Carter
31 thg 3, 20269 phút đọc

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." If web depends on @repo/ui, Turborepo will build ui before starting web. 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 (like NEXT_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 commandturbo build lint test type-check runs 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 outputs for 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.json with careful dependsOn, inputs, outputs, and env declarations
  • 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": true on internal packages to prevent accidental publishing
  • Use --filter for targeted builds and deployments; use --dry=json for 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan