Skip to content

Turbo Remote Cache

Purpose — How MJ’s CI build cache is wired, how to work with it locally, and how to operate it (rotate the key, revoke access, turn it off). Read before changing anything under .github/workflows/ that runs turbo, before adding a workflow that builds packages, or when CI has become mysteriously slower.

MJ builds ~300 packages. Turborepo caches each package’s build/test output keyed by a hash of its inputs, so unchanged packages replay instead of recompiling. The remote cache (hosted by Vercel) shares those artifacts across runs, branches, and machines — which is the one thing the GitHub Actions cache cannot do, since its entries are scoped per-ref and can only be read downward from the default branch.

Concretely: a package built on next this morning replays in your PR this afternoon without recompiling.


Every way of breaking this is silent. Nothing here fails a build:

Broken howWhat turbo doesWhat you see
No TURBO_TOKENFalls back to local cacheRemote caching disabled in the log
No TURBO_TEAMCannot resolve a scopeRequests fail; job rebuilds
No signature key (signing on)Reads cache, uploads nothingsigning artifact failed once per run
Reusable workflow without secrets: inheritEmpty tokenLooks configured — vars propagate, secrets don’t
Everything workingSigns and uploadsNothing. No success message at all.

Every row is a green build. The only symptom is CI gradually getting slower for reasons nobody can attribute to a change.

Two mechanisms exist because of this, and both must stay green:

  • check-turbo-cache-env.mjs — a preflight step in test.yml’s build job and release-test.yml’s unit-tests job. Fails the run on an incoherent env, and writes the state to the step summary so a healthy cache is visible rather than merely un-complained-about.
  • turbo-remote-cache-coverage.test.mjs — asserts every job that invokes turbo carries all four vars, that the token and signature key travel together, that reusable-workflow callers pass secrets, and that test.yml’s trigger paths cover every workflow it checks.

Four environment variables, declared at job level on every job that runs turbo:

env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_API: ${{ vars.TURBO_API }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
NameKindValue
TURBO_TOKENOrganization secretA Vercel access token scoped to the team
TURBO_TEAMOrganization variableThe Vercel team slug (not a secret — it appears in dashboard URLs)
TURBO_APIOrganization variableDeliberately unset. Only for a self-hosted cache server
TURBO_REMOTE_CACHE_SIGNATURE_KEYOrganization secretHMAC key for artifact signing

Scope the secrets to selected repositories, not “all repositories” — TURBO_TOKEN is a cache-write credential, and any repo that can read it can push artifacts other repos restore into builds.

  1. Add all four vars to the job’s env:.
  2. Add the workflow to test.yml’s pull_request paths: list.

The coverage test fails if you skip either. Step 2 is not optional bookkeeping — test.yml is path-filtered, so a workflow missing from that list is one whose cache wiring can be broken by a PR that never runs the guard.

Secrets are not inherited across uses:. A called workflow receives none unless the caller passes them:

release-validation:
uses: ./.github/workflows/release-test.yml
secrets: inherit # ← without this, TURBO_TOKEN is empty in the called workflow

This is genuinely treacherous because vars do propagate. Omit secrets: inherit and the job has TURBO_TEAM set and TURBO_TOKEN empty — it reads as configured while the cache is off. This shipped: the publish path ran without a remote cache from the day the cache was added until it was found in review.

GitHub withholds secrets from pull_request runs originating in forks, so external contributions get a local cache only. This is correct and expected — the preflight passes with no token precisely so it never reds an external contribution.

No MJ workflow uses pull_request_target or issue_comment, so fork-authored code never executes with these credentials in scope. Keep it that way. Adding either trigger to a workflow holding TURBO_TOKEN would hand a cache-write credential to unreviewed code.


turbo.json sets:

"remoteCache": { "signature": true }

Turbo then HMAC-SHA256-signs every artifact it uploads and verifies every artifact it downloads. Anything failing verification is treated as a cache miss — it is discarded, not replayed.

What this protects against: tampering or corruption of artifacts at rest in Vercel’s storage or in transit.

What it does not protect against: every job shares one key, so a signature proves an artifact came from a key-holder, not which one. Any job that can write can produce a validly signed artifact. This is tamper-evidence for the storage layer, not a defense against a compromised job inside the trust boundary. Narrow the blast radius with secret scoping (above), not with signing.


You do not need any of this. With no credentials, turbo uses the local .turbo/cache and everything works — signature: true never engages. This is the default and it is fine.

If you want to share the CI cache:

Terminal window
npx vercel login
npx turbo login
npx turbo link

Then also export the signature key, or your uploads silently fail:

Terminal window
export TURBO_REMOTE_CACHE_SIGNATURE_KEY='<same value as the org secret>'

Without it every local build logs signing artifact failed: signature secret key not found and uploads nothing. Your builds still work and still read from the cache — you just stop contributing, and nothing tells you beyond one warning line. Get the value from whoever administers the Vercel team; it is the same string CI uses.


Rotation invalidates every previously signed artifact — they become unverifiable and are treated as misses. Expect one full cold rebuild across all jobs. Sequence:

  1. Generate: openssl rand -base64 32
  2. Update the TURBO_REMOTE_CACHE_SIGNATURE_KEY organization secret.
  3. Tell anyone using the cache locally to update their exported value.
  4. Expect the next run of each workflow to be cold. Hit rates recover on the run after.

There is no dual-key or overlap window — turbo verifies against one key. Rotate at a quiet time, not before a release.

Cheaper: TURBO_TOKEN does not participate in artifact hashing, so a new token reads the existing cache normally.

  1. Mint a replacement (Vercel → Account Settings → Tokens, scoped to the team).
  2. Update the organization secret.
  3. Revoke the old token.

Tokens carry the creating user’s access. Mint it under a service/bot account, not a personal one, or the cache dies when that person’s access changes.

Unset the TURBO_TOKEN secret. Everything degrades to local caching, the preflight reports INACTIVE and stays green, and no workflow edits are required. Do not half-disable it by removing the signature key — that leaves jobs reading the cache and never writing to it.

If the access token was created with an expiration, put the renewal date somewhere visible. On expiry turbo reports Remote caching disabled and CI silently gets slower — the preflight cannot catch this, because the variable is still set; only a live round-trip would reveal it.


SymptomCause
Remote caching disabled on a same-repo branchToken unset, revoked, or expired; or a reusable workflow missing secrets: inherit
signing artifact failed: signature secret key not foundThe signature key is missing in that job — it is reading the cache and uploading nothing
Everything rebuilds after a config changeEditing turbo.json changes the global hash. Expected once
Everything rebuilds and nobody changed anythingThe signature key was rotated, or artifacts were signed with a different key
A new workflow gets no cache hitsMissing the four vars, or missing from test.yml’s paths:. The coverage test catches both
Preflight fails with TURBO_TEAM is emptyThe variable is not set, or is scoped to repositories that exclude this one