How We Scaled Our Turborepo Remote Cache to Save 40 Hours a Week

Stop letting your continuous integration runners recompile the exact same unchanged workspace packages fifty times a day.

VW
Viery Wahyu•Author
•7 min read

Monorepos promise architectural harmony until your continuous integration bill arrives at the end of the month. When we consolidated our Next.js storefronts, shared UI libraries, Drizzle database schemas, and backend domain engines into a single pnpm workspace, code sharing across teams became effortless. Six months later, however, our deployment velocity was quietly suffocating under the weight of redundant computation. Every time an engineer pushed a one-line CSS tweak to a leaf application, our continuous integration runners spun up fresh containers and spent twelve minutes running type checks, unit tests, and production builds across twenty-four upstream packages that had not changed in weeks. Multiply that twelve-minute wait across dozens of daily pull requests and local branch switches, and our engineering team was burning over forty hours every single week just staring at terminal progress bars.

Why Local Caching Alone Cannot Save a Growing Team

Enabling Turborepo locally was our first step, and it immediately solved the problem of running the same build twice on a single laptop. Under the hood, Turborepo treats every package task as a deterministic pure function. Before executing a build or lint script, it computes a cryptographic SHA-256 hash of the package source files, internal workspace dependencies, lockfile slice, and relevant environment variables. If that exact hash already exists inside the local .turbo/cache folder, Turborepo skips execution entirely and replays the terminal logs and compiled dist folders in milliseconds.

The limitation of local caching is that every developer machine and every ephemeral continuous integration container starts life with an empty cache directory. When an engineer builds a shared core engine on their MacBook and pushes the branch to GitHub, the CI runner has no idea that the exact same cryptographic hash was already verified thirty seconds ago on a trusted machine. Even worse, when another teammate pulls the main branch an hour later, their laptop compiles those exact same packages for a third time.

Deploying a Self-Hosted Remote Cache Server Over Object Storage

Bridging that isolation gap required wiring all of our developer workstations and CI runners into a shared Turborepo Remote Cache. While managed cloud caching works fine for small projects, streaming gigabytes of Next.js build artifacts and .tsbuildinfo caches across a high-velocity fleet can quickly rack up bandwidth charges on usage-based tiers. Because Turborepo uses an open, well-documented REST specification for its remote cache client, we built and deployed our own lightweight cache server backed by zero-egress S3-compatible object storage inside our own infrastructure.

Whenever the Turborepo CLI runs with a configured teamId and token, it sends GET and PUT requests to /v8/artifacts/:hash carrying the compressed task output stream. Implementing the storage handler required less than fifty lines of code using pre-signed object streams and bearer token authentication.

typescript
1import { Hono } from "hono"
2import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"
3
4const s3 = new S3Client({
5 region: "auto",
6 endpoint: process.env.R2_STORAGE_ENDPOINT!,
7 credentials: {
8 accessKeyId: process.env.R2_ACCESS_KEY_ID!,
9 secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
10 },
11})
12
13const app = new Hono()
14
15app.use("/v8/artifacts/*", async (c, next) => {
16 const authHeader = c.req.header("Authorization")
17 if (authHeader !== `Bearer ${process.env.TURBO_REMOTE_CACHE_TOKEN}`) {
18 return c.text("Unauthorized Cache Client", 401)
19 }
20 await next()
21})
22
23app.put("/v8/artifacts/:hash", async (c) => {
24 const hash = c.req.param("hash")
25 const teamId = c.req.query("teamId") ?? "default"
26 const bodyBuffer = await c.req.arrayBuffer()
27
28 await s3.send(
29 new PutObjectCommand({
30 Bucket: process.env.CACHE_BUCKET_NAME!,
31 Key: `${teamId}/${hash}.tar.zst`,
32 Body: new Uint8Array(bodyBuffer),
33 ContentType: "application/octet-stream",
34 })
35 )
36
37 return c.json({ urls: [`${teamId}/${hash}`] }, 202)
38})
39
40app.get("/v8/artifacts/:hash", async (c) => {
41 const hash = c.req.param("hash")
42 const teamId = c.req.query("teamId") ?? "default"
43
44 try {
45 const response = await s3.send(
46 new GetObjectCommand({
47 Bucket: process.env.CACHE_BUCKET_NAME!,
48 Key: `${teamId}/${hash}.tar.zst`,
49 })
50 )
51 return new Response(response.Body as ReadableStream, {
52 headers: { "Content-Type": "application/octet-stream" },
53 })
54 } catch {
55 return c.text("Artifact Not Found", 404)
56 }
57})
58
59export default app

Placing this stateless proxy on the edge right in front of our object storage bucket turned every single machine in our company into a distributed build node. The moment a developer or a staging runner compiles a package once, the compressed .tar.zst archive lands in the bucket and becomes instantly downloadable by every subsequent pipeline run across the globe.

Hunting Down Silent Cache Misses From Environment Drift

Deploying the remote cache server was only half the battle, because during our first week in production, our CI cache hit ratio sat at a disappointing thirty-five percent. Tasks that had zero source code changes were still computing completely different hashes on developer laptops compared to our Linux CI containers. To find out why Turborepo thought the inputs were different, we ran our builds with the --summarize flag, which outputs a detailed JSON manifest showing every single file hash and environment variable that contributed to the final task signature.

Diffing the summary manifests between a local run and a CI run exposed two classic monorepo traps. First, our CI runner was injecting dynamic environment variables like CI_COMMIT_TIMESTAMP and GITHUB_RUN_ID into the global shell environment, and because our turbo.json configuration had not switched to strict environment mode, accidental pass-through configurations were polluting the build hash on every single run. Second, we had forgotten to exclude local .env.local files and transient test coverage folders from the task input globs.

json
1{
2 "$schema": "https://turbo.build/schema.json",
3 "globalDependencies": ["tsconfig.base.json", "pnpm-lock.yaml"],
4 "globalEnv": ["NODE_ENV"],
5 "envMode": "strict",
6 "tasks": {
7 "build": {
8 "dependsOn": ["^build"],
9 "inputs": ["$TURBO_DEFAULT$", "!.env.local", "!README.md", "!**/*.test.ts"],
10 "env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_TELEMETRY_RPC_URL"],
11 "outputs": [".next/**", "!.next/cache/**", "dist/**", "tsconfig.tsbuildinfo"]
12 },
13 "check-types": {
14 "dependsOn": ["^check-types"],
15 "inputs": ["$TURBO_DEFAULT$", "!**/*.md"],
16 "outputs": ["tsconfig.tsbuildinfo"]
17 }
18 }
19}

Locking down turbo.json with envMode set to strict and stripping non-essential markdown and test files from the build inputs pushed our remote cache hit rate above ninety-two percent overnight. Notice how we also excluded .next/cache/** from the remote build outputs while explicitly including tsconfig.tsbuildinfo. Excluding the massive internal webpack cache kept our uploaded archive sizes small and fast to download over the network, while caching the TypeScript incremental build info file meant even when a leaf package experienced a cache miss, tsc only had to type-check the modified files rather than starting from cold memory.

Turning Twelve-Minute Waits Into Twelve-Second Verifications

Scaling our Turborepo Remote Cache fundamentally changed the rhythm of our engineering organization. Today, when a developer opens a pull request that modifies a single checkout component or tweaks a backend workflow step, the continuous integration runner pulls down pre-compiled artifacts for the other twenty-three packages in less than four seconds, verifies the single touched package, and prints the coveted FULL TURBO banner in under twenty seconds. By reclaiming forty hours of wasted weekly wait time across our team and slashing our CI compute minutes by seventy percent, we proved that the fastest build step is always the one you never have to run twice.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →