Home DevOps

How to Set Up CI/CD for a Monorepo with GitHub Actions

DevOps

September 28, 2026

How to Set Up CI/CD for a Monorepo with GitHub Actions

A monorepo makes plenty of things simpler. One clone, one set of tooling, one pull request that can change an API, its client library and the docs that describe it. It also makes your CI bill grow in a way that feels unfair: fix a typo in a README and twenty jobs wake up, each rebuilding packages nobody touched.

GitHub Actions can handle this neatly, but not by accident. You need to know which files changed, work out which projects those changes affect, and let everything else stay asleep. Here is a pipeline design that does exactly that.

Why a monorepo pipeline has to be selective

A simple workflow that runs every task in every package works fine with three packages. With thirty, feedback takes twenty minutes, runners queue up, and developers start batching changes to avoid the wait — which is the opposite of what continuous integration is for.

The aim is proportionality. A change to one leaf package should build that package and the things that depend on it, and nothing else. Everything else is noise. Once you accept that, the workflow splits into three questions: what changed, what does that affect, and what should happen as a result.

Work out what changed, and what it affects

GitHub's paths filter on workflow triggers is the first tool most people reach for, and it is the wrong one on its own. It cannot express dependency graphs. Change a shared utility package and paths will happily let the workflow skip the four applications that import it.

Two things work better, and you can combine them:

  • A task runner that understands the graph. Turborepo, Nx, Rush and Moon all model dependencies between packages. Turbo has --filter='...[origin/main]', which selects every package changed since main plus its dependents. Nx offers the equivalent through nx affected. Both give you the affected set in one command.
  • A diff against the merge base. If you are doing this by hand, compare against the point where the branch diverged, not the tip of main. Something like git diff --name-only origin/main...HEAD with three dots does the right thing. Using two dots is a common bug, and it quietly tests the wrong set of files.

For coarse gating — "skip the whole workflow for documentation-only changes" — an action such as dorny/paths-filter is fine. Just keep the dependency graph as the source of truth for build and test decisions.

A workflow skeleton that stays readable

The pattern that ages best is one small job that decides, then downstream jobs that read its outputs. The decision job checks out the repository with full history, runs your affected command, and publishes a JSON matrix or a set of boolean outputs.

changes → outputs: api, web, shared
build-and-test: needs: changes, if: needs.changes.outputs.api == 'true'

Downstream jobs then use if conditions so that untouched packages genuinely do nothing. Full history matters here: a shallow checkout will not let you diff against main, so set fetch-depth: 0 on the checkout step in that first job.

You can also generate a dynamic matrix from the affected list, which gives parallel jobs and a tidy list of pass-or-fail checks in the pull request. It is more moving parts than a single job running the whole graph, so start with the single job and split later if the time saved justifies it.

Cache properly, and install once per job

Monorepo builds are usually dominated by dependency installation. Two habits help:

  1. Key the cache on the lockfile. Use actions/cache or the built-in cache in actions/setup-node with cache: npm or pnpm, and include the lockfile hash in the key. Add a restore-key that falls back to the previous key for the same OS so a single dependency bump does not throw everything away.
  2. Use npm ci or the pnpm equivalent rather than a plain install. Reproducible installs catch lockfile drift early, which matters more when a dozen packages share one lockfile.

Remote caching is the real win

Turbo and Nx both support remote caching, where task outputs are stored centrally and reused across branches and developers. A job that rebuilds a shared library and finds the artefact already cached finishes in seconds. You can self-host the cache or use a managed one; either way, set it up once and every workflow benefits.

Split long tasks by stage

Lint, typecheck, unit tests and build have different failure meanings. Running them as separate steps inside the same job is fine, and it keeps the pipeline easy to read. Cache the build output between jobs with an artefact upload if the deploy job needs the compiled result.

Deploy only what changed

Deployment jobs should reuse the same affected outputs. Gate them on the default branch, and add a concurrency group so two merges cannot deploy the same environment at once — for production, set cancel-in-progress: false so a run finishes rather than being cut off halfway. Protect production with a GitHub Environment and required reviewers if you want a human in the loop.

Order matters when packages depend on each other: deploy the shared library before the apps that consume it, or version it and let consumers pick it up on their own schedule. Keep deploys idempotent, so a re-run after a flaky network error is harmless. For cloud credentials, prefer short-lived tokens issued through OIDC over long-lived secrets stored in the repository.

The branch protection trap

Here is the problem nobody warns you about. If a job is skipped by an if condition, its check does not report as successful — it reports as skipped, and a required status check that never succeeds keeps the pull request blocked.

The reliable fix is a single gate job. Give it a name such as CI, make it depend on all the conditional jobs with needs, run it with if: always(), and have it fail when any dependency failed or was cancelled. Then mark only that gate as a required check in branch protection. The jobs underneath it can skip freely.

Small habits that save hours

  • Use the merge base for diffs, never the head of main.
  • Do not rely on paths filters for anything that has dependents.
  • Keep a nightly full build and test run so drift in untouched packages still surfaces.
  • Quarantine flaky tests instead of retrying everything three times.
  • Print the affected package list in the job summary — debugging is much faster when you can see what the pipeline thought it was doing.

A sensible starting point

Do not build all of this on day one. Begin with a workflow that runs the graph through affected filtering, plus caching keyed to the lockfile. Confirm that a change to a single package only builds that package and its dependents. Then add remote caching, then deploy gating, then the gate job and branch protection.

If your fast path — lint, typecheck, unit tests — finishes in under ten minutes, developers will run it happily and often. That is the whole point. A pipeline that is quick enough to trust beats a thorough one that everybody works around.

Photo: Pexels / Pixabay

Related Posts

Developer Laptop Setup Checklist for New UK Hires
Tools

October 10, 2026

Developer Laptop Setup Checklist for New UK Hires

A practical checklist for setting up a secure, comfortable development laptop as a new UK hire, from disk encryption and access requests...

read more
A Beginner's Guide to Database Normalisation for Small Business Apps
Databases

October 09, 2026

A Beginner's Guide to Database Normalisation for Small Business Apps

A practical introduction to first, second and third normal forms, with clear examples showing how to structure small business data...

read more
How to Run Zero-Downtime Database Migrations
Databases

October 07, 2026

How to Run Zero-Downtime Database Migrations

Practical steps for changing production schemas without downtime: the expand-and-contract pattern, lock-aware statements, deploy...

read more