Nx monorepo with Nx Cloud cache

In a monorepo, every change raises the same question: which services does it affect, and which need a deploy? With Nx on Pipemesh:

  • Each service gets its own pipeline. A revision reaches only the services whose Nx inputs changed.
  • A deploy runs only when the service's build output changed.
  • Builds share Nx Cloud's remote cache. It replays any task a build already ran, on any service's pipeline, from any earlier revision. The default branch writes to it, and pull requests only read it.

The demo, pipemesh/demo-nx, has five services on three shared libraries, in TypeScript with npm workspaces, bundled with esbuild and tested with Node's built-in test runner. Its pipelines are public on pipemesh.io.

libs/money   ─┬─ orders    payments   catalog
libs/events  ─┼─ orders    inventory  notifications
libs/http    ─┴─ orders    payments   inventory  catalog

What happens on a change

Measured on the demo (try them on a fork or a copy):

Change Services that receive it Deploys
A README edit none (only the graph job runs) —
A comment in libs/money orders, payments, catalog none: esbuild drops comments, so the bundles are identical
A new test in orders orders none: the bundle is identical
A behaviour change in libs/money orders, payments, catalog those three
The service pipeline's config all five none: the bundles are identical

With its test and build tasks from Nx Cloud, a service build takes 6–7 s on a warm runner. The fingerprint job takes 9 s.

Set it up

1. The workspace

  • Nx knows the dependency graph. With npm workspaces, a service's package.json lists the libraries it uses ("@demo/money": "*"), and Nx infers the edges. Each deployable service has a build target, which is how the component finds 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).
  • The image has Node.js, bash, git, curl and tar. Pipemesh's hosted runner needs the last four in every image. node:24-bookworm has them all; Alpine images don't (Hosted runners).

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:
    # A build: it checks out the whole tree, since Nx reads all of it.
    graph:
      job_type: build
      stage: graph
      uses: nx/fingerprint@1
      with:
        image: node:24-bookworm
        extra: .pipemesh/service.yaml deploy
      produces:
        orders: fingerprints/orders
        payments: fingerprints/payments
        # … one entry per service

    # One child pipeline per service. It checks out nothing: the entry it
    # consumes is its only input.
    orders:
      job_type: pipeline
      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 hashes the input files of each service's build and test targets, as nx show target inputs lists them. Nx hashes the same files for its own cache: the service's files, the files of every library it depends on, the project and workspace config, and the lockfile when the targets use external packages. extra adds what Nx can't see: the service pipeline's config and the deploy scripts.
  • graph is a build: it reuses an earlier run when nothing in the repository 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.

nx/fingerprint@1 takes these parameters:

Parameter Default What it is
image (required) The job's image: bash, git and Node.js.
install npm ci --no-audit --no-fund Installs the workspace's packages, Nx among them. Empty to skip.
with_target build Every project with this target gets a fingerprint.
targets build test The targets whose inputs make up the fingerprint.
extra none Paths added to every fingerprint; a directory covers every file under it.
out fingerprints Where the files go.

Each file is named after the project, without its npm scope: @demo/orders writes fingerprints/orders.

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-nx/-/pipeline/orders.

type: pipeline
stages:
  - build
  - staging
  - production
jobs:
  # A build checks out the whole tree: npm ci and Nx read all of it.
  build:
    job_type: build
    stage: build
    image: node:24-bookworm
    secrets:
      - NX_CLOUD_ACCESS_TOKEN
    variables:
      NX_DAEMON: "false"
      NX_CLOUD_DISABLE_METRICS_COLLECTION: "true"
    script: |
      npm ci --no-audit --no-fund
      npx nx run-many -t test build -p @demo/$SERVICE
      mkdir -p dist && cp apps/$SERVICE/dist/$SERVICE.js dist/
    produces:
      bundle:
        path: "dist/*.js"
  deploy_staging:
    job_type: deploy
    production: false
    stage: staging
    image: node:24-bookworm
    consumes:
      - build/bundle
    checkout:
      - deploy
    script: deploy/deploy.sh $SERVICE staging
  deploy_prod:
    job_type: deploy
    production: true
    stage: production
    image: node:24-bookworm
    needs:
      - deploy_staging
    consumes:
      - build/bundle
    checkout:
      - deploy
    script: deploy/deploy.sh $SERVICE production

The build's NX_CLOUD_* settings are explained in 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 library function the service doesn't use) builds, tests and stops.

4. Pull request checks: .pipemesh/checks.yaml

Pull requests test and build only what the change affects, against the merge base, reading Nx Cloud's cache.

type: workflow
stages:
  - test
jobs:
  # A task: what it tests depends on the merge base, not only on the
  # tree, so it runs on every pull request revision. Nx reads the whole tree.
  affected:
    job_type: task
    stage: test
    checkout: true
    image: node:24-bookworm
    variables:
      NX_DAEMON: "false"
      NX_CLOUD_DISABLE_METRICS_COLLECTION: "true"
    script: |
      npm ci --no-audit --no-fund
      base=$(git merge-base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" HEAD)
      npx nx affected -t test build --base="$base" --head=HEAD

It reports as pipemesh/checks/affected, so it can be a required check. It works with GitHub's merge queue too (GitHub integration): demo-nx merges every pull request through it.

5. Nx Cloud: writes from the default branch only

  1. Connect the workspace. Run npx nx connect, or start from cloud.nx.app. Either adds nxCloudId to nx.json, which is how every run finds the workspace. Nx Cloud's GitHub app is optional: it adds comments on pull requests, but doesn't authenticate Pipemesh's jobs.
  2. Make token-less runs read-only. In the workspace's Settings → Access control, set Default access level to read-only. That covers every run without a token: pull requests, forks, anyone with a clone. A new workspace may default to read-write, which lets anyone who can read nx.json write cache entries your deploys would ship.
  3. Create a CI access token. On the same page, under CI access tokens, click New access token with read-write permission.
  4. Store it in Pipemesh as a secret named NX_CLOUD_ACCESS_TOKEN, in the repository's Settings. Nx reads that variable on its own.

Only the service build job lists the secret, and it runs only on the default branch. Pull-request runs never receive a secret unless the secret allows it, so the checks read the cache with the default access and can't write to it.

Set NX_CLOUD_DISABLE_METRICS_COLLECTION on every job that runs Nx. In CI, Nx Cloud uploads each run's CPU and memory metrics to a "run group" only its own CI integrations create. Under Pipemesh there is no such group, so without this variable every job spends about a minute retrying the upload after its tasks are done.

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.

Optional: a faster image

demo-nx builds its own CI image, ghcr.io/pipemesh/demo-nx-ci, and its jobs pin it by digest. It's node:24-bookworm-slim with the runner's tools and the npm cache for the lockfile baked in, so npm ci installs offline. It's about a third of the size of node:24-bookworm, so a new runner node starts a job sooner.

A ci_image workflow runs on every push. Its one job is a build that checks out only what the image bakes in: ci/, .dockerignore, the root package.json and lockfile, and each project's package.json. A push that changes none of them reuses the last build.