A Practical Guide to Dramatically Reducing Monorepo CI Build Times with Turborepo Remote Caching and Task Pipelines
After setting up release automation with pnpm workspaces and Changesets in a previous post, you tend to feel a peculiar sense of confidence — like you've finally got the monorepo workflow figured out. Then one day you open a PR and watch CI grind away for 20 minutes, and that confidence starts to waver. I've been there. Things were fine with three or four packages, but once we crossed ten, build times started scaling linearly.
Turborepo solves this in two ways. The first is task pipelines — you declare dependency relationships so that tasks with no interdependencies run in parallel. The second is remote caching — build results are shared across the entire team and CI so that only what changed gets rebuilt. The Mercari engineering team took the same approach and published results showing a 50% reduction in CI task execution time. There are also stories of 20-minute pipelines shrinking to 2 minutes in 10-person startups.
This post assumes you're already running a monorepo with pnpm workspaces. The goal is to set up Turborepo from scratch and achieve a cache hit rate of 85% or higher. I've covered everything from the initial installation to connecting GitHub Actions with remote caching, and the pitfalls you're likely to step into along the way. Fair warning upfront: the Vercel Remote Cache requires a Vercel account, and forgetting to declare outputs completely disables caching. These are the two most common early mistakes, so I'm mentioning them first.
Core Concepts
Task Pipelines — Maximizing Parallelism with a Dependency Graph
One reason builds are slow in a monorepo is the "just run things in order" pattern. Build packages/ui, then packages/hooks, then apps/web. But if packages/utils and packages/hooks don't depend on each other, why not run them at the same time?
When you declare task dependencies in turbo.json, Turborepo analyzes the graph and automatically runs tasks that can safely be parallelized concurrently.
What the
^prefix means:"dependsOn": ["^build"]means "thebuildtask of every package this package depends on must complete first." Without the^, writing just"build"means "thebuildtask within the same package must run first."
The absence of dependsOn on lint is intentional. Linting only needs to look at source files — it's independent of build output — so it can run fully in parallel with the build.
Remote Caching — The Whole Team Shares the Same Cache
Locally, Turborepo stores its cache in a .turbo folder. But CI starts fresh in a new container every time, so there's no local cache to speak of. Remote caching solves this.
Here's how it works: before running a task, Turborepo computes a hash of the source files, environment variables, and other inputs. If the remote cache server already has build artifacts for that hash, the task is skipped entirely and the cached artifacts — including logs — are restored instead.
Cache Miss: A situation where the cached result can't be reused because the inputs have changed — the source files were modified, a dependency version changed, or an environment variable is different.
Environment variables are particularly important in cache key computation. In turbo.json, you can specify them at two levels:
| Level | Key | Scope |
|---|---|---|
| Global | globalEnv |
Included in the cache key for every task |
| Per-task | env (inside tasks) |
Applies only to that specific task |
If you put something like CI_COMMIT_SHA or BUILD_TIME — values that change on every run — into globalEnv, every task's cache will miss forever. I made this mistake myself during initial setup: the cache appeared to be working, but the hit rate stayed at 0%. The recommendation is to scope dynamic variables to per-task env, or restructure things so they're excluded from cache key computation entirely.
Comparing Remote Cache Options
There are three options depending on where you host your remote cache:
| Option | Characteristics | Notes |
|---|---|---|
| Vercel Remote Cache | Officially supported, minimal setup | Free tier available, requires a Vercel account |
| ducktors/turborepo-remote-cache | Open-source self-hosted | Supports S3, GCS, Azure Blob Storage |
| GitHub Actions Cache | No Vercel account needed | Uses actions like rharkor24/caching-for-turborepo |
Practical Application
Adding Turborepo to an Existing pnpm Workspace Monorepo
If you're already using pnpm workspaces, the barrier to entry is low. Install the package and create a turbo.json — that's the entire basic setup.
pnpm add turbo --save-dev -wCreate a minimal turbo.json at the root:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".next/static/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}The empty array [] in outputs is also intentional. Tasks like lint and test that produce no file artifacts still need outputs declared explicitly for caching to work correctly. I once omitted this during initial setup and the cache appeared to work, but nothing was actually restored on the next run.
If you need long-running processes like a dev server, upgrade to the extended version:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", ".next/static/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}Update the scripts in package.json as well:
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"dev": "turbo run dev"
}
}| Config key | Purpose |
|---|---|
dependsOn: ["^build"] |
Run after dependent packages' builds complete |
outputs |
Glob patterns for files to cache. Empty arrays must still be explicitly declared |
cache: false |
For tasks where caching is meaningless, like a dev server |
persistent: true |
Marks long-running processes (dev servers, etc.) |
If you're on Turborepo 1.x, you'll be using the pipeline key — it was renamed to tasks in 2.0. Migration is a single command:
npx @turbo/codemod migrateConnecting GitHub Actions to Vercel Remote Cache
This is the most common combination. The setup is simpler than you'd expect.
First, go to the Vercel dashboard at vercel.com/account/tokens and click "Create Token." Then register two values in your GitHub repository under Settings → Secrets and variables:
- Secrets:
TURBO_TOKEN— the Vercel token you just created - Variables:
TURBO_TEAM— your Vercel team slug (or your Vercel username for personal accounts)
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
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: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lintOnce the TURBO_TOKEN and TURBO_TEAM environment variables are set, Turborepo automatically connects to Vercel Remote Cache. No additional flags or configuration needed.
fetch-depth: 0: Affected filters like--filter="...[origin/main]"require git history to work. The defaultfetch-depth: 1(shallow clone) may not be able to find the comparison commit, so this setting fetches the full history.
When you see >>> FULL TURBO in the second CI run's logs, cache hits are occurring.
The Affected Pattern — Running Only Changed Packages
This pattern becomes increasingly valuable as your repo grows. In one real-world case, 90 jobs in a single PR shrank to 8 — all thanks to this pattern.
# Build only packages changed relative to main, plus their dependents
pnpm turbo build --filter="...[origin/main]"
# Test all packages that depend on @myrepo/ui
pnpm turbo test --filter="@myrepo/ui..."The --filter syntax can look a little unfamiliar at first:
| Pattern | Meaning |
|---|---|
@myrepo/ui |
That package only |
@myrepo/ui... |
That package + all packages that depend on it |
...@myrepo/ui |
That package + all packages it depends on |
...[origin/main] |
Packages changed relative to main, plus affected packages |
In CI, combine these like so:
- name: Build affected packages
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected packages
run: pnpm turbo test --filter="...[origin/main]"Separating Responsibilities with Changesets
If you already have Changesets set up, it divides responsibilities cleanly with Turborepo — the two don't conflict.
Turborepo focuses on "building and testing fast," while Changesets handles "what gets published at which version." In my experience, keeping these two roles separate in production is the cleanest approach.
One thing to watch out for: when the release job runs on a separate runner and executes changeset publish, it needs the build artifacts (dist/) to be present. If the build-and-test job doesn't pass the artifacts along, there's nothing to publish — it'll either fail or deploy empty packages.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lint
- uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: packages/*/dist
release:
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- uses: changesets/action@v1
with:
publish: pnpm changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}| Job | Responsibility |
|---|---|
build-and-test |
Orchestrates build, test, and lint with Turborepo; uploads build artifacts |
release |
Receives artifacts, then handles versioning and npm publishing with Changesets |
Debugging Cache Misses — Dry Run and Devtools
When the cache misses unexpectedly, tracking down the cause isn't always straightforward. Two tools can help:
# See which tasks are cache hits/misses as JSON, without actually running anything
pnpm turbo run build --dry=jsonStarting with Turborepo 2.7, Devtools is included. Run turbo devtools or npx turbo devtools to visually explore the package and task graph in your browser. It's especially useful for intuitively tracing which package is causing a cache miss.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Immediate performance gains | Cache hits can take a 6-minute build down to 45 seconds. Once you exceed 85% hit rate, CI costs drop noticeably too |
| Low adoption barrier | Drop a turbo.json into an existing pnpm workspace, install the package, and you're done in under 10 minutes |
| Consistent build results | Only what actually changed gets rebuilt based on the dependency graph, making builds predictable |
| Automatic parallelism | Tasks with no dependency relationship run in parallel automatically, maximizing multi-core utilization |
| Graph visualization (v2.7+) | turbo devtools lets you explore the package/task graph in a browser, making it easy to spot cache miss paths |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
outputs declaration is mandatory |
Omitting it completely disables caching | Declare explicitly for every task; empty arrays [] are valid |
| Environment variable complexity | Dynamic variables in cache keys cause permanent misses | Scope dynamic variables to per-task env, or exclude them |
| Vercel account dependency | The official remote cache requires a Vercel account | Use ducktors/turborepo-remote-cache or GitHub Actions Cache |
| Large monorepo limitations | Cross-domain dependencies can get complex, requiring file-level static analysis | Consider Nx when dependency relationships become too intricate |
| Cache miss debugging difficulty | Unexpected cache invalidation can be hard to trace | Use --dry=json or Devtools (v2.7+) |
--dry=json: Outputs which tasks would run and their cache hit/miss status as JSON, without executing anything. A good first tool for debugging cache issues.
The Most Common Mistakes in Practice
-
Omitting
outputs— The cache appears to work, but files aren't restored on the next run. Always specify build output paths likedist/**or.next/**, and put"outputs": []for tasks that produce no artifacts (e.g., lint, test). -
Including dynamic variables in
globalEnv— Variables likeCI_COMMIT_SHAorBUILD_TIMEinglobalEnvcause every task's cache to miss on every run. Scope these to per-taskenv, or restructure so they're excluded from cache key computation and injected after the build step. -
Dumping all global variables into the root
.env— When the root.envchanges, every package's cache is invalidated all at once. Split.envfiles per package, or use per-taskenvarrays to specify only the variables each task actually needs.
Closing Thoughts
Honestly, I started out skeptical — "how much can cache settings really matter?" But once the cache hit rate crossed 85%, I could feel the entire CI cycle change. When a 20-minute CI drops to 2–3 minutes, the whole PR review flow transforms. I can say with confidence: this is the highest-ROI monorepo optimization you can make relative to the setup cost.
Three steps you can take right now:
- Install with
pnpm add turbo --save-dev -w, create the minimalturbo.jsonabove at the root, and runpnpm turbo buildonce to see it in action. - Generate a Vercel token, add
TURBO_TOKENandTURBO_TEAMto GitHub Secrets/Variables, and run CI twice — check whether you see>>> FULL TURBOin the second run's logs. - If your CI is unconditionally building every package, add
--filter="...[origin/main]"to switch to the affected pattern.
Next post: Using Turborepo Boundaries to enforce dependency rules between monorepo packages and maintain architectural boundaries in code
References
- Configuring Tasks | Turborepo Official Docs
- Remote Caching | Turborepo Official Docs
- Caching | Turborepo Official Docs
- Turbo 2.0 Release Notes | Turborepo Blog
- Turbo 2.7 Release Notes (Devtools) | Turborepo Blog
- GitHub Actions CI Integration Guide | Turborepo Official
- Accelerating CI with Turborepo Remote Cache | Mercari Engineering
- Optimizing CI with Turborepo Remote Caching | Leapcell
- Turborepo 2.0: Deep Dive into Remote Caching & Task Pipelines | DEV Community
- 6 Turborepo vs Nx Patterns That Cut Monorepo CI Time by 70% | DEV Community
- Building a Fast CI Pipeline with Turborepo + pnpm | Tinybird
- The Complete Guide to GitHub Actions Monorepos | WarpBuild
- ducktors/turborepo-remote-cache — Open-Source Self-Hosted Server
- Setting Up Turborepo Remote Cache with S3 and GitHub Actions
- Turborepo Devtools | Official Page
- Vercel Remote Cache | Vercel Blog