Turborepo monorepo with Vercel Remote Cache
In a monorepo, every change raises the same question: which services does it affect, and which need a deploy? With Turborepo on Pipemesh:
- Each service gets its own pipeline. A revision reaches only the services Turborepo's task graph says it can affect.
- A deploy runs only when the service's build output changed.
- Builds share Vercel Remote Cache. It replays any task a build already ran, on any service's pipeline. Builds sign in with the job's Pipemesh identity, so no key is stored anywhere.
The demo, pipemesh/demo-turborepo, has five services on three shared libraries, built with pnpm, Turborepo and esbuild. Its pipelines are public on pipemesh.io.
libs/money ─┬─ orders payments catalog
libs/events ─┼─ orders inventory notifications
libs/http ─┴─ every service
What happens on a change
Measured on the demo (try them on a fork or a copy):
| Change | Services that receive it | Deploys |
|---|---|---|
A behaviour change in libs/money |
orders, payments, catalog | orders and payments. Catalog builds and tests, then skips both deploys: it doesn't use the changed function, so its bundle is byte-identical. |
| A README edit | none (only the graph job runs) | — |
| A new test in orders | orders | none: the bundle is identical |
On that last change, six of the orders build's eight Turborepo tasks (every library build and test) came from the remote cache.
Set it up
1. The workspace
- Libraries have a
buildtask, even one that only type-checks (tsc -p .). A service'sbuilddepends on^build, which is how a library's files reach the service's task hash. Without one, the library is invisible to it. - The artifact is reproducible. esbuild writes the same bytes for the same inputs, on any machine. Without that, every build is a new digest and the deploys never skip. Dispatch still works: it relies on the fingerprint.
2. The dispatch pipeline: pipemesh.yaml
This is the repository's pipeline. A graph job fingerprints each
service, and one dispatch job per service hands the revision to that
service's pipeline.
service: !include .pipemesh/service.yaml
dispatch:
type: pipeline
stages:
- graph
- dispatch
jobs:
graph:
job_type: build # checks out the whole workspace: turbo hashes all of it
stage: graph
uses: turbo/fingerprint@1
with:
image: node:22-bookworm
install: corepack enable && pnpm install --frozen-lockfile
turbo: pnpm -s turbo
deployables: services/ # one fingerprint per package here
extra: .pipemesh/service.yaml deploy
produces:
orders: fingerprints/orders
payments: fingerprints/payments
# … one entry per service
orders:
job_type: pipeline # checks out nothing: the entry it consumes is its input
stage: dispatch
consumes:
- graph/orders
body: !ref service
variables:
SERVICE: orders
# … one dispatch job per service
pipemesh:
pipelines:
pipeline: !ref dispatch
workflows:
checks:
body: !include .pipemesh/checks.yaml
on: pull_request
- The fingerprint is Turborepo's hash of the service's
buildtask, fromturbo run build --dry=json. It covers the service's files, the build tasks of every workspace package it depends on (throughdependsOn: ["^build"]), the external dependencies it resolves from the lockfile, and the root's dependencies.extraadds what the hash can't see: the service pipeline's config and the deploy scripts. - Each fingerprint file is named after the package, without its npm scope.
graphis a build: it reuses an earlier run when nothing in the workspace changed.- A dispatch job hands the revision over when its entry differs from what the service last received, not from the previous commit. A service held back for ten commits still gets all ten when the next change reaches it.
3. The service pipeline: .pipemesh/service.yaml
This is service: a type: pipeline body, the shape a
job_type: pipeline job takes (The definition).
Each service runs its own copy with SERVICE set. Each copy has its
own history, board and URL, such as
/github.com/pipemesh/demo-turborepo/-/pipeline/orders.
type: pipeline
stages:
- build
- staging
- production
jobs:
build:
# Checks out the whole workspace: pnpm and turbo read every package.
job_type: build
stage: build
setup:
- uses: vercel/turborepo-token@1
with:
team: <your Vercel team slug>
- uses: turbo/remote-cache@1
with:
team: <your Vercel team slug>
image: node:22-bookworm
script: |
corepack enable
pnpm install --frozen-lockfile
# The service and every workspace package it depends on.
pnpm turbo run build test --filter="$SERVICE..."
mkdir -p dist && cp services/$SERVICE/dist/$SERVICE.mjs dist/
produces:
bundle:
path: "dist/*.mjs"
deploy_staging:
job_type: deploy
production: false
stage: staging
image: node:22-bookworm
consumes:
- build/bundle
checkout:
- deploy
script: deploy/deploy.sh $SERVICE staging
deploy_prod:
job_type: deploy
production: true
stage: production
image: node:22-bookworm
needs:
- deploy_staging
consumes:
- build/bundle
checkout:
- deploy
script: deploy/deploy.sh $SERVICE production
The two setup: components connect the build to the cache
(step 5). The deploys
consume the esbuild bundle and check out only deploy/, so they run on
a new bundle or a changed deploy script. A change that
leaves the bundle byte-identical (a test, a comment, a type-only edit, a
library function the service doesn't use) builds, tests and stops.
4. Pull request checks: .pipemesh/checks.yaml
Pull requests build and test only what the change affects, against the merge base.
type: workflow
stages:
- test
jobs:
affected:
# A task: it reads the pull request's merge base, so it runs on every
# pull request revision, with the whole workspace and full history.
job_type: task
stage: test
checkout: true
image: node:22-bookworm
script: |
corepack enable
pnpm install --frozen-lockfile
export TURBO_SCM_BASE=$(git merge-base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" HEAD)
pnpm turbo run build test --affected
It reports as pipemesh/checks/affected, so it can be a required check.
It works with GitHub's merge queue too
(GitHub integration).
5. Vercel Remote Cache, with no stored key
Each build job requests a Pipemesh identity token for Vercel's audience.
Vercel exchanges it for a short-lived Turborepo token.
GitHub integration explains identity tokens and
how to find a job's sub.
On Vercel, as a team owner:
Turn on Remote Cache in the team's Settings → Build and Deployment → Remote Caching.
Add an OIDC policy. On the same page, under OIDC Policies, click Add next to Turborepo CLI Policies:
Field Value Policy name anything, e.g. pipemesh <repository>Provider Custom provider Issuer URL https://pipemesh.io/api/oidcaudhttps://vercel.com/<team slug>subthe service buildjobs' subjects, comma-separatedCopy the subjects from the repository's Settings → Job identity, or
pipemesh identity. The demo has onebuildjob per service.Put the team's slug in both components'
team:. It's the part aftervercel.com/in the team's URL.
No Vercel project is needed: the cache and the policy belong to the team. When it works, each build log shows:
[vercel/turborepo-token] Turborepo token for team <slug>
[turbo/remote-cache] remote cache (Vercel) for team <slug>
vercel/turborepo-token@1exportsTURBO_TOKEN. When a request is refused (no matching policy, an expired token), it logs the reason and exports nothing.audience:changes the requestedaud, for a policy that expects another value.turbo/remote-cache@1points turbo at the cache. Without a token it builds without the remote cache, instead of failing. On pull-request runs the cache is read-only (TURBO_CACHE=local:rw,remote:r). For a self-hosted Turborepo cache, drop the Vercel step and pass the server asapi:and the secret holding its token astoken_var:, besideteam:.
Keep pull-request jobs out of the policy. The Turborepo token can write to the cache, and on a public repository anyone can open a pull request. Left out, pull-request checks build without the remote cache. The service pipelines only run on revisions that reached the default branch.
6. Enable the repository
Enable it as in Getting started. The first revision dispatches every service, since none has received anything yet. After that, only what changed.