Setting Up Turborepo for a Production Monorepo
A complete guide to configuring Turborepo for monorepo management, including workspace setup, task caching, and remote build optimization.
Introduction
Monorepos have become the standard architecture for teams managing multiple related packages. After migrating several client projects from separate repositories to a unified monorepo, I learned that choosing the right build orchestration tool matters more than most developers realize. Turborepo, created by Vercel, offers incremental builds, intelligent caching, and parallel task execution that can dramatically reduce build times in large codebases.
In this post, I will walk through setting up a Turborepo monorepo from scratch, configure task pipelines, implement caching strategies, and address common pitfalls that trip up teams during adoption.
Environment
Before starting, ensure your development environment meets these requirements:
node --version
# v20.11.0
pnpm --version
# 8.15.0
# Install Turborepo globally
npm install -g turbo
turbo --version
# 2.0.0The project structure we are building will look like this:
my-monorepo/
βββ apps/
β βββ web/ # Next.js frontend
β βββ api/ # Express backend
βββ packages/
β βββ ui/ # Shared UI components
β βββ config/ # Shared TypeScript configs
β βββ utils/ # Shared utility functions
βββ turbo.json
βββ package.json
βββ pnpm-workspace.yamlProblem
The initial challenge was migrating three separate repositories into a single monorepo while maintaining independent deployment pipelines. The legacy setup had duplicated dependencies, inconsistent TypeScript configurations, and build times exceeding 15 minutes for CI pipelines. Each repository had its own node_modules folder totaling over 2GB of disk space.
The specific issues included:
- Duplicate utility functions across repositories with slightly different implementations
- Inconsistent ESLint and Prettier configurations
- No shared type definitions between frontend and backend
- CI/CD pipelines running full builds for every pull request regardless of which packages changed
Analysis
After evaluating several monorepo tools including Nx, Lerna, and Rush, I chose Turborepo for several reasons. First, its zero-configuration approach meant less setup overhead. Second, the content-based caching system ensures that unchanged packages are never rebuilt. Third, the remote caching feature allows team members and CI systems to share build artifacts.
The root cause of the slow CI pipelines was the lack of dependency awareness. Without a monorepo tool, every build triggered a complete rebuild of all packages. Turborepo solves this through its task pipeline system defined in turbo.json, which maps out the dependency graph between packages and only rebuilds what changed.
Here is how Turborepo determines what to rebuild:
# When you run turbo run build, it:
# 1. Analyzes the dependency graph
# 2. Checks the cache for each package
# 3. Only rebuilds packages with changed source code
# 4. Stores new build artifacts in the cache
turbo run build --dry
# Tasks: 6 total
# Cached: 3 (50%)
# Executed: 3Solution
First, initialize the workspace root. Create the pnpm-workspace.yaml file:
packages:
- "apps/*"
- "packages/*"Update the root package.json:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"turbo": "^2.0.0",
"prettier": "^3.2.0"
}
}Create the turbo.json pipeline configuration:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
}
}
}For the shared packages/utils package, create a minimal package configuration:
{
"name": "@my-monorepo/utils",
"version": "0.0.1",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"test": "vitest run"
},
"devDependencies": {
"typescript": "^5.3.0",
"vitest": "^1.2.0",
"eslint": "^8.56.0"
}
}Enable remote caching by creating a turbo.json at the root with team credentials:
# Connect to Vercel Remote Cache (free for open source)
turbo login
turbo link
# Or configure self-hosted remote cache
export TURBO_TOKEN="your-api-token"
export TURBO_TEAM="your-team-name"
export TURBO_API="https://your-remote-cache.com"For the apps/web Next.js application, install the Turborepo plugin:
cd apps/web
pnpm add @vercel/turborepo-plugin-nextjsUpdate the web app's turbo.json to use the plugin:
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}To verify the setup works correctly:
# Run all builds in dependency order
turbo run build
# Run only the web app and its dependencies
turbo run build --filter=web
# Run everything except tests
turbo run build lint --filter=!utils
# Clear the local cache
turbo cleanLessons Learned
After running this Turborepo setup in production for several months, here are the key takeaways:
Cache invalidation is not automatic when using external data sources. You need
inputsconfiguration to specify which files affect each task's output.Remote caching saves significant time in CI/CD. Our GitHub Actions build times dropped from 15 minutes to under 4 minutes after enabling remote caching.
Package naming conventions matter. Use scoped packages with a consistent prefix like
@my-org/to avoid conflicts and make imports clear.The
^prefix independsOnmeans "this package's dependencies must build first." Without it, Turborepo runs tasks in parallel without waiting for dependencies.Purge your cache periodically. Build artifacts accumulate and can consume significant disk space. Use
turbo prunefor Docker builds to create minimal package subsets.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.