Optimizing CI/CD for Large-Scale Monorepos with Turborepo and Remote Caching
As TypeScript monorepos scale beyond a dozen packages, the developer experience (DX) often degrades. Build times creep from seconds to minutes, and CI pipelines become a bottleneck for deployment velocity. The primary culprit is usually redundant work: re-running tests, linting, and builds for packages that haven't changed.
In this post, we will explore how to implement Turborepo to orchestrate tasks and leverage remote caching to achieve near-instant CI runs.
The Problem: The O(n) Build Trap
In a traditional monorepo setup using basic workspace scripts (e.g., npm run build --workspaces), the build time is linear relative to the number of packages. Even with a single-line change in a leaf package, the entire dependency graph is often rebuilt because the build system lacks the intelligence to understand the impact of changes.
This leads to several engineering pain points:
- High CI Costs: Running full test suites on every PR consumes significant compute resources.
- Context Switching: Developers wait 15+ minutes for CI feedback, leading to fragmented focus.
- Flaky Failures: The more tasks you run, the higher the probability of a transient failure affecting the entire pipeline.
Enter Turborepo: Task Orchestration and Hashing
Turborepo solves this by creating a Directed Acyclic Graph (DAG) of your project's tasks. It uses a sophisticated hashing algorithm to determine if a task's inputs (source files, environment variables, and dependencies) have changed since the last run. If the hash matches, Turborepo skips the execution and replays the cached output.
Defining the Pipeline
The core of Turborepo is the turbo.json file. This is where you define the relationships between tasks. For example, you cannot build a package until its dependencies are built.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**/*.test.ts", "src/**/*.test.tsx"]
},
"lint": {}
}
}
In this configuration, the ^build syntax ensures that all upstream dependencies are built before the current package. By specifying outputs, Turborepo knows exactly which files to cache and restore.
Implementing Remote Caching
Local caching is great for individual developers, but the real power comes from Remote Caching. This allows your CI runners and every developer on the team to share a single cache. If a colleague has already built a specific version of a shared UI library, your machine will simply download the artifacts instead of compiling them.
CI Integration with GitHub Actions
To implement this in a production CI environment, you need to authenticate your runners with a cache provider (like Vercel or a self-hosted S3-compatible backend).
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build and Test
run: pnpm turbo run build test lint --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
The --remote-only flag is a best practice for CI to ensure that the local runner's disk doesn't get cluttered with cache files that are already available in the cloud.
Advanced Pattern: Pruning for Docker Builds
One of the biggest challenges in monorepos is creating lean Docker images. If you copy the entire monorepo into a Docker context, the build cache is invalidated on every single file change. Turborepo provides a prune command to solve this.
turbo prune --scope=web-app generates a subset of your monorepo containing only the files needed to build the web-app package. This drastically reduces the Docker context size and improves layer caching.
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g turbo
COPY . .
RUN turbo prune --scope=web-app --out-dir=out
FROM node:20-alpine AS installer
WORKDIR /app
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install
COPY --from=builder /app/out/full/ .
RUN pnpm turbo run build --filter=web-app
Tradeoffs and Considerations
While Turborepo significantly improves DX, it is not a silver bullet. Engineers must be mindful of:
- Environment Variables: If your build depends on environment variables, they must be explicitly declared in
turbo.json. Otherwise, you might serve a build cached withSTAGINGvariables in aPRODUCTIONenvironment. - Cache Poisoning: If a task is non-deterministic (e.g., it includes a timestamp in the output), the cache will be invalidated every time. Ensure your build outputs are deterministic.
- Monorepo Size: For extremely large repos (thousands of packages), the overhead of graph construction can become noticeable, though Turborepo's Rust-based core handles this better than legacy tools.
Conclusion
Transitioning to a task-aware build system like Turborepo is one of the highest-ROI improvements you can make to a TypeScript monorepo. By moving from O(n) builds to O(changes) builds, you reclaim developer time and reduce infrastructure costs. Start by migrating your most frequent tasks (lint and test) and gradually move toward full remote caching for your build artifacts.