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 build task, even one that only type-checks (tsc -p .). A service's build depends 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 build task, from turbo run build --dry=json. It covers the service's files, the build tasks of every workspace package it depends on (through dependsOn: ["^build"]), the external dependencies it resolves from the lockfile, and the root's dependencies. extra adds 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.
  • graph is 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:

  1. Turn on Remote Cache in the team's Settings → Build and Deployment → Remote Caching.

  2. 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/oidc
    aud https://vercel.com/<team slug>
    sub the service build jobs' subjects, comma-separated

    Copy the subjects from the repository's Settings → Job identity, or pipemesh identity. The demo has one build job per service.

  3. Put the team's slug in both components' team:. It's the part after vercel.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@1 exports TURBO_TOKEN. When a request is refused (no matching policy, an expired token), it logs the reason and exports nothing. audience: changes the requested aud, for a policy that expects another value.
  • turbo/remote-cache@1 points 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 as api: and the secret holding its token as token_var:, beside team:.

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.