Overview
CI builds in our monorepo had been getting slower and slower. As packages piled up and more apps were added, I spent more and more time zoning out waiting for the build to finish after opening a single PR.
We were already using Turborepo, and we were using actions/cache to cache the .turbo directory.
But the cache didn't work as well as I'd hoped. Even a rebase wiped it out, and branches couldn't share caches with each other.
In the end I set up Turborepo's Remote Cache myself, and here's a write-up of how that went.
A Quick Recap of Turborepo Caching
The core idea of Turborepo is never doing the same work twice. It hashes each task's inputs (source files, environment variables, dependencies) to make a cache key, and if the inputs are identical, it skips execution and restores the previous output as-is.
turbo run build
→ 해시 계산: abc123def
→ 캐시 확인: .turbo/cache/abc123def.tar.zst
→ HIT → 빌드 스킵, 결과물 복원 ✅
The hash is split into a Global Hash and a Task Hash, and if either one changes, it's a cache MISS.
- Global Hash —
turbo.jsonsettings, the root lockfile,globalDependencies,globalEnv - Task Hash — the package's source files,
package.json,envenvironment variables, and the hashes of dependent packages
The important point here is that the hash is based on the actual file contents, not the commit SHA. You can make 100 commits, and if the code hasn't changed, it's still a cache HIT.
Locally, .turbo/cache sticks around, so this caching works nicely.
CI was the problem.
Why actions/cache Wasn't Enough
GitHub Actions runners start from a clean environment on every run.
There's no .turbo/cache from the previous build, so everything has to be built from scratch each time.
So we used actions/cache to cache the .turbo directory.
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-When the cache key matches, .turbo gets restored and Turborepo uses it as a local cache.
At a glance it seemed to work fine, but in practice cache MISSes happened more often than I expected.
When I dug into the cause, I found that actions/cache has some structural limitations.
- Cache key based on the commit SHA — even if you don't change any code, just doing an
amendorrebasechanges the SHA and breaks the cache - Isolated per branch — the cache from feature branch A can't be used on feature branch B
- Size limit — 10GB on GitHub's free tier, and entries are automatically deleted after 7 days without use
Put together, you get this mismatch:
actions/cache: f(runner.os, github.sha) ← 커밋 ID 기반
Turborepo 해시: f(파일 내용, env, deps, config) ← 실제 내용 기반
No matter how smartly Turborepo calculates its hash, it's pointless if the cache storage itself is isolated per commit. To fix this at the root, we needed Turborepo's Remote Cache.
Switching to Remote Cache
Remote Cache stores cache artifacts on a remote server, so anything with the same hash can be reused regardless of branch or environment.
Turborepo officially offers Vercel Remote Cache, but we were already running our services on GCP.
Rather than creating a new dependency on Vercel, I chose to self-host it inside our existing infrastructure.
I could also put the cache server in the Seoul region (asia-northeast3), which keeps latency to the CI runners as low as possible.
actions/cache | Remote Cache | |
|---|---|---|
| Cache scope | Isolated per branch | Shared across the whole team |
| Cache key | Commit SHA (managed manually) | Source hash (calculated automatically) |
| Sharing across teams | Not possible | Possible |
| Local integration | Not possible | Possible |
| Setup complexity | Low | High (requires setting up infrastructure) |
Implementation
Turborepo's Remote Cache API is simpler than you might think.
GET /v8/artifacts/:hash to download, PUT /v8/artifacts/:hash to upload.
Every request includes an Authorization: Bearer {TURBO_TOKEN} header, and the server handles authentication with that token.
Following this spec, I built the cache server myself on Cloud Run and stored the artifacts in a GCS bucket.
GitHub Actions (CI)
│ TURBO_TOKEN, TURBO_TEAM, TURBO_API
▼
Cloud Run (캐시 서버)
▼
GCS 버킷 (coloso-turbo-cache)
Here's a quick summary of the steps:
- Create a GCS bucket (Seoul region)
- Deploy the cache server to Cloud Run
- Set Cloud Run to public access — the Turbo CLI's token isn't an IAM token, so I opened up Cloud Run-level authentication and validate the token inside the server
- Add environment variables to the GitHub Actions workflow
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: my-team
TURBO_API: ${{ secrets.TURBO_REMOTE_CACHE_URL }}The TURBO_TOKEN in GitHub Secrets and the authentication token on the Cloud
Run cache server must be exactly the same value.
Once you've set all this up, it switches to the Remote caching enabled state.
Troubleshooting
The implementation itself wasn't hard, but a few unexpected things ate up some time.
Apple Silicon + Docker — The Docker image I built locally spat out exec format error on Cloud Run. Apple Silicon builds for linux/arm64 by default, but Cloud Run needs linux/amd64. Specifying --platform linux/amd64 fixes it.
Cloud Run authentication — Cloud Run has IAM authentication on by default. The TURBO_TOKEN sent by the Turbo CLI isn't an IAM token, so I got a 401. I gave allUsers the roles/run.invoker role to switch to public access, and handled security with token validation inside the server.
413 Payload Too Large — This one threw me for a bit. Cloud Run's HTTP/1.1 has a 32MB limit per request, and the Next.js build output blew past it with ease. Enabling HTTP/2 with the --use-http2 option fixed it right away.
Boosting the Cache Hit Rate
Attaching Remote Cache isn't the end of the story. How well you set up turbo.json makes a big difference in the hit rate.
The change that paid off the most for us was trimming down globalEnv.
We used to have 8 variables crammed into globalEnv, but only two, NODE_ENV and D1_ENV, were actually needed globally. The rest only affect the build, so I moved them into each task's env.
// 개선 전
{
"globalEnv": [
"D1_ENV", "NODE_ENV", "ASSET_URL", "USE_DYNAMIC_PORTS",
"NEXT_PHASE", "FLIPT_URL", "FLIPT_API_TOKEN", "FEATURE_FLAG_SIDEBAR_ENABLED"
]
}
// 개선 후
{
"globalEnv": ["NODE_ENV", "D1_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": ["ASSET_URL", "USE_DYNAMIC_PORTS", "NEXT_PHASE"],
"inputs": ["src/**", "app/**", "components/**", "lib/**",
"next.config.*", "tsconfig.json", "tailwind.config.*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {
"inputs": ["src/**/*.{ts,tsx,js,jsx}", "eslint.config.*"],
"outputs": []
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "vitest.config.*", "tsconfig.json"],
"outputs": []
}
}
}With this, the problem where changing just ASSET_URL blew away the lint/test caches too is gone.
And thanks to specifying inputs, editing the README no longer triggers a build.
Also, since a monorepo mixes packages with different build tools, I created a turbo.json per package and overrode inputs/outputs.
{
"extends": ["//"],
"tasks": {
"build": {
"inputs": ["app/**", "components/**", "hooks/**", "lib/**", "styles/**"],
"outputs": [".next/**", "!.next/cache/**"],
},
},
}{
"extends": ["//"],
"tasks": {
"build": {
"inputs": ["client/**", "server/**", "scripts/**"],
"outputs": ["dist/**", "dist-api/**"],
},
},
}{
"extends": ["//"],
"tasks": {
"build": {
"inputs": ["src/**", "tsconfig.json", "tsdown.config.ts"],
"outputs": ["dist/**"],
},
},
}If a Vite app has .next/** outputs configured, it just piles up meaningless cache, so getting even these details right noticeably improves the hit rate.
You Can Use It Locally Too
Remote Cache isn't just for CI.
Put the server URL in .turbo/config.json and set only TURBO_TOKEN, and you can use the cache built in CI right on your local machine.
{ "teamId": "my-team", "apiUrl": "<TURBO_REMOTE_CACHE_URL>" }A package cache built by teammate A can be reused by teammate B, and the main branch's CI cache can be used for local development on a feature branch.
If the environment variables declared in globalEnv differ between CI and
local, the hash changes and you get a cache MISS.
Final Thoughts
Since switching from actions/cache to Remote Cache, the problem of full builds running just because of a rebase has completely disappeared.
After cleaning up globalEnv, specifying inputs, and adding per-package overrides, the cache hit rate shot up.
There's a lot to configure, admittedly. Cloud Run, the GCS bucket, and authentication all need attention. But once it's set up, cache sharing across branches and teammates just happens automatically, so it was definitely a worthwhile investment.
Until not long ago I was a bit skeptical, feeling like AI was taking the fun out of development, but lately my mind has changed. For this project too, I was able to move quickly with Claude Code, from analyzing the Remote Cache API spec to configuring Cloud Run and optimizing turbo.json. Something I'd have struggled with on my own, I can now get done faster, and I can really feel it.
References