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.jsonlists the libraries it uses ("@demo/money": "*"), and Nx infers the edges. Each deployable service has abuildtarget, 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,curlandtar. Pipemesh's hosted runner needs the last four in every image.node:24-bookwormhas 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
buildandtesttargets, asnx show target inputslists 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.extraadds what Nx can't see: the service pipeline's config and the deploy scripts. graphis 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
- Connect the workspace. Run
npx nx connect, or start from cloud.nx.app. Either addsnxCloudIdtonx.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. - 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.jsonwrite cache entries your deploys would ship. - Create a CI access token. On the same page, under CI access tokens, click New access token with read-write permission.
- 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_COLLECTIONon 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.