Turbo Remote Cache: How Monorepo CI Dropped from 18min to 1min

10 min read

# Turbo Remote Cache: how Tessel's monorepo CI went from 18min to 1min This post documents the migration of Tessel's CI — a pnpm monorepo with 24 packages (Nex

Turbo Remote Cache: how Tessel’s monorepo CI went from 18min to 1min

This post documents the migration of Tessel’s CI — a pnpm monorepo with 24 packages (Next.js apps, Cloudflare workers, internal libraries) — to a model with turbo run --affected + Turbo Remote Cache. The measured result: 18min37s → 1min11s in warm runs, with a setup that took about two hours and has some caveats worth noting.

The honest TL;DR: the gain did not come from --affected alone, and it actually came very little from the remote cache either — until we realized the cache was silently turned off by an empty variable. Half of this post is the story of that trap.

The starting point

The CI ran in a single sequential job: install → lint → build all → type-check all → test all. Average time: 18 minutes. For each PR, even if it touched a single package, all 24 entered the pipeline.

The traditional solution for this is matrix + filters — running only the affected packages. Turborepo 2.x already offers --affected based on git diff and dependency graph. The sequence was:

  1. Migrate deploy from N individual workflows (deploy-admin.yml, deploy-bot.yml, …) to a single orchestrator (deploy-affected.yml) that calls turbo run deploy --affected --dry-run=json and generates a matrix of packages for deployment.
  2. Add Turbo Remote Cache via Vercel to reuse artifacts between runs.

The first part was mechanical and worked immediately. The second part seems simple in the documentation (TURBO_TOKEN + TURBO_TEAM in the env) and was where we almost went wrong.

The single orchestrator

Before:

.github/workflows/
├── deploy-admin.yml
├── deploy-bot.yml
├── deploy-workers.yml
├── deploy-upload-service.yml
├── ... (more 13)

Each workflow had its own paths: filter. Pushing a PR that changed the pnpm-lock.yaml triggered 17 parallel deploys — expensive, noisy, and each one repeated install/build from scratch.

The replacement is a single workflow that detects what changed and generates a matrix:

jobs:
  detect:
    outputs:
      packages: ${{ steps.affected.outputs.packages }}
      has-affected: ${{ steps.affected.outputs.has-affected }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Detect affected packages
        id: affected
        run: |
          AFFECTED_JSON=$(pnpm exec turbo run deploy --affected --dry-run=json 2>/dev/null || echo '{"tasks":[]}')
          PACKAGES=$(echo "$AFFECTED_JSON" | jq -c '[.tasks[] |
            select(.taskId | endswith("#deploy")) |
            select(.command != "<NONEXISTENT>") |
            .package] | unique')
          echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
 
  deploy:
    needs: detect
    if: needs.detect.outputs.has-affected == 'true'
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.packages) }}
    steps:
      - run: pnpm exec turbo run build --filter=${{ matrix.package }}^...
      - run: pnpm --filter=${{ matrix.package }} run --if-present type-check
      - run: pnpm --filter=${{ matrix.package }} run --if-present test
      - run: pnpm --filter=${{ matrix.package }} run deploy

Two details that cost time if you find out late:

1. pnpm --filter X deploypnpm --filter X run deploy. The first is interpreted as the built-in subcommand pnpm deploy (which extracts a package to a standalone directory) and gives ERR_PNPM_INVALID_DEPLOY_TARGET. The second invokes the npm script. I had to replace it in 13 workflows after the first deploy failed.

2. select(.command != "<NONEXISTENT>") is necessary. The turbo --dry-run lists all packages that would have a deploy task, including those that inherited the task from the global config but do not define the script. Without the filter, the matrix tries to deploy packages that do not have a deploy script.

The silent trap of the cache being turned off

With the orchestrator running, I added the remote cache:

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

I configured TURBO_TOKEN as a secret. I submitted the PR. The runs started coming in faster — 18min → 6min. Victory declared. I posted on Slack: “remote cache active.”

It was not active.

The clue came when someone asked “how much did we optimize with the cache?”. To answer with a number, I went to read the log and searched for cache hit:

test-and-lint  Build all packages  cache miss, executing 5352a88e5a20c90e
test-and-lint  Build all packages  cache miss, executing c9aa709f399a688b
test-and-lint  Build all packages  cache miss, executing a5c98db22de0510d
... (24 cache miss, 0 cache hit)

All 24 builds were cache misses. I looked at the env of the step:

TURBO_TOKEN: ***
TURBO_TEAM:

TURBO_TEAM was empty. Turbo silently skips the remote cache when one of the two variables is missing — no warning, no error, nothing in the logs except “cache miss”. I had created TURBO_TOKEN as a secret and forgot to create TURBO_TEAM as a variable.

And the 6 minutes? They came from something else: the recent changes only affected workflow files, and the CI’s “Detect changes” step categorized them as global=false, so it skipped Build all packages (global change) and went straight to the final unconditional step Build all packages — which ran with Turbo’s local cache (within the same runner) and deduplicated part of the work. The speedup was an artifact of the change scope, not cross-run cache.

The operational lesson: always measure what you think you optimized. If I had run two identical pushes in a row (same source SHA, deterministic build), the second should have been instantaneous. It was not. It was obvious in retrospect.

Correct setup

To activate the remote cache via Vercel:

  1. Get the Vercel team slug (not the ID — the slug is what goes in TURBO_TEAM):
# via Vercel MCP or REST API:
curl https://api.vercel.com/v2/teams \
  -H "Authorization: Bearer $VERCEL_TOKEN" | jq '.teams[] | {name, slug}'
# → "journeystudios"
  1. Create a token in Vercel with scope on the correct team.

  2. Add as GitHub repo configs:

gh secret set TURBO_TOKEN --body "$VERCEL_TOKEN"
gh variable set TURBO_TEAM --body "journeystudios"

The distinction between secret/variable matters: the token goes in secrets (sensitive), the slug goes in variables (public, and exposing in logs helps debug).

  1. Ensure that TURBO_TOKEN and TURBO_TEAM are in the environment of all jobs that run turbo tasks, not just the specific step:
jobs:
  test-and-lint:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Job-level, not step-level. Turbo reads at CLI initialization; configuring only in the build step leaves the previous steps (which also invoke turbo) without cache.

Real measurement

With TURBO_TEAM=journeystudios set, I made two identical runs via workflow_dispatch:

Cold run (empty cache, populating):

Total: 03:46:54 → 03:53:06  (6m12s)
Build all packages: ~5min, 24/24 "cache miss, executing"

Warm run (same SHA, no changes):

Total: 03:53:52 → 03:55:03  (1m11s)
Build all packages:
  cache hit, replaying logs 5352a88e5a20c90e
  cache hit, replaying logs c9aa709f399a688b
  ... (24/24 cache hit)
  Tasks:    24 successful, 24 total
   Time:    3.299s >>> FULL TURBO

3.3 seconds to “build” 24 packages. The >>> FULL TURBO is literal — Turbo prints when 100% of the tasks were cached and there was no real execution, just replay of saved logs.

Comparison:

RunStateTotal timeBuild step
Pre-cache (PR 46)no remote cache~18m37s~14min
Cold (populating)TURBO_TEAM ok6m12s~5min
Warm (FULL TURBO)full reuse1m11s3.3s

Build step reduction: 14min → 3s (~99.6%). Total CI reduction: 18min → 1min (~94%).

The other ~67s of the warm run are pnpm install (tarball cache already came from actions/cache), restore of node_modules, and setup of the runner — overhead that Turbo cannot eliminate.

Where cache doesn’t help

To calibrate expectations:

Global changes reset everything. turbo.json, root package.json, pnpm-lock.yaml, tsconfig.base.json, biome.json — any of these changes the input hash of practically all tasks. Cold start again.

Cache miss in intermediate packages cascades. If you change code in @tessel/utils, all packages that depend on it (~15 of the 24) become cache miss. The cache is by input hash, and transitive change changes input.

Tasks with side effects should not be cached. deploy is the clear example — you can’t “replay” the output of wrangler deploy. In turbo.json:

"tasks": {
  "deploy": {
    "cache": false,
    "dependsOn": ["build", "type-check"]
  }
}

The inference here is that we cache what is deterministic (build, lint, type-check, test) and let deploy always execute. The gain is the dependsOn — if the build came from the cache, the expensive part has already passed.

Logs of cached tasks come from the replay, not the current run. This confuses debugging. If you are hunting a build bug, force --force or delete the local cache before assuming that the output that appeared is the current one.

Cost

Vercel Remote Cache on hobby/pro account: free for free tier, with generous limits (200GB/month of transfer on Pro). Tessel is well within the free tier — the cached artifacts of 24 builds + tests + type-checks total a few MB per hash, and the default TTL of 7 days keeps the working set small.

GitHub Actions: the bill dropped together. Before, each PR burned ~18min of runner (including concurrent jobs). Now most warm PRs burn ~1min. For a team of 3 devs with ~50 PRs/month, it’s a real difference in minute-runner.

What changed in DX

The most visible effect is not the “1 minute” on the CI badge — it’s the predictability. When the engineer opens a PR and sees the CI pending, before it was “I’ll do something else for 20 minutes”. Now it’s “I’ll wait”. The feedback window fell within the context of the review, and the temptation to merge without a green CI disappeared.

The second effect is the confidence in rebasing. Before, rebase = a new round of 18min. Now, if the rebase does not introduce semantically different changes, the CI comes back in 1-2min. It reduces the friction of keeping PRs in sync with main.

General Lessons

  1. Measure before declaring victory. “The CI got faster” without a cache hit in the log is not evidence of the cache working. It is evidence that something changed — it could be the cache, it could be the scope of change, it could be a faster runner. Confirm with the direct signal.

  2. Silent failures in build tools are expensive. Turbo could log “WARN: TURBO_TEAM not set, remote cache disabled”. It doesn’t. Other build tools do the same. The defense is to set up a smoke test: run the build twice in sequence and require that the second one shows >>> FULL TURBO or cache hit.

  3. Variables vs secrets in GitHub Actions matter. Variables appear in the logs (TURBO_TEAM: journeystudios), secrets become ***. For debugging “is it configured?”, put everything that is not sensitive in variables — you will want to read the value one day.

  4. Matrix + filter + remote cache combine. Each optimization alone has a marginal gain. Combined, they multiply: matrix parallelizes, --affected reduces the set, remote cache reuses what has already been done. The right step is to apply in this order (parallelize > filter > cache), because matrix without filter is expensive and cache without parallelism loses trivial gains.

  5. Cache key is everything. If the Turbo hash includes a file that changes every hour (timestamp generated, non-deterministic version.generated.ts, CI env var in the input), the cache is useless. It is worth auditing turbo.json regularly to ensure that inputs and env are restricted to what actually affects the output.

Closure

The migration took about two hours of real work and an extra hour debugging the forgotten variable. The return is spread out over each PR from now on — probably ~15min saved per developer per day, on average. For a large monorepo setup, it’s one of the best ROI investments in package DevOps.

If you are building a Turbo + GitHub Actions monorepo in 2026: start with matrix --affected, enable remote cache on the first day, and from the beginning run two consecutive workflow_dispatch to confirm that the second is FULL TURBO. It’s the smoke test that prevents declaring victory too early.