Tips

CI/CD for a pnpm Monorepo with GitHub Actions

Caching, affected-package builds, and matrix jobs so CI stays fast as a NestJS + Next.js monorepo grows past a handful of apps.

CI/CD for a pnpm Monorepo with GitHub Actions

A CI pipeline that lints, tests, and builds every package on every push works fine with two apps. By the fourth or fifth app in the monorepo, a one-line change to a README triggers the same fifteen-minute pipeline as a change to the payment module — and the team starts ignoring CI because it is slow, not because it is wrong.

Cache the package manager, not just the build output

The single biggest speed win in most pipelines is caching pnpm's content-addressable store between runs, keyed on the lockfile hash. A cache hit turns pnpm install from a multi-minute network-bound step into a few seconds of local linking, and it invalidates automatically the moment a dependency actually changes — no manual cache-busting required.

- uses: pnpm/action-setup@v4
  with: { version: 9 }

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: 'pnpm'

- run: pnpm install --frozen-lockfile

Only build what a change actually touches

Path filters on the workflow trigger, or a tool like turbo / nx with dependency-graph awareness, let a change confined to apps/web skip the server's test suite entirely. The rule that keeps this safe: a change to a shared package (packages/shared) must still trigger every app that depends on it — the filter has to follow the dependency graph, not just the literal file path.

  • Run lint and typecheck before tests — they are faster and catch a large fraction of failures before paying for a database-backed test run.
  • Use a matrix strategy (strategy: matrix: app: [server, web, admin]) to run each app's pipeline in parallel jobs instead of one long sequential script.
  • Spin up Postgres and Redis as GitHub Actions service containers for integration tests, so CI does not depend on an external environment.
  • Fail fast on the cheapest check, but still run the full matrix on main even if one job fails, so you see the complete picture rather than one failure at a time.
  • Pin action versions to a commit SHA or a major version tag, not @master — a third-party action changing behavior underneath you is a supply-chain risk, not just an inconvenience.

A minimal pipeline shape that scales

Three stages cover most needs: a fast lint-and-typecheck job that runs on every push, a test job with service containers that runs on every push to a PR branch, and a build-and-push job gated on main that builds the Docker image and pushes it to a registry only after the first two succeed.

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: test }
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter server test:e2e
        env:
          DB_HOST: localhost
          DB_PASSWORD: test

A service container is disposable by design — it starts empty on every run. Run migrations as an explicit CI step before the test suite, the same way a fresh production database would need them, instead of assuming schema state.

Deploying only the apps a change actually touched

Building every Docker image on every merge to main wastes registry storage and deploy time once a monorepo has several deployable apps. A deploy job gated by the same path-filter logic as the test job — comparing the changed files against each app's directory and its declared dependencies — pushes and deploys only the images that actually need a new version.

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      server: ${{ steps.filter.outputs.server }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            server:
              - 'apps/server/**'
              - 'packages/shared/**'
            web:
              - 'apps/web/**'
              - 'packages/shared/**'

  deploy-server:
    needs: changes
    if: needs.changes.outputs.server == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building and deploying apps/server..."
  • A change to packages/shared must appear in every app's filter list, or a shared-package change silently skips deploying the apps that actually depend on it.
  • Tag images with the commit SHA, not just latest, so a rollback is "redeploy this exact tag" rather than a guess about which build was good.
  • Keep a manual workflow_dispatch trigger available to force a full rebuild-and-deploy of every app, for the rare case the filter logic itself needs bypassing.

Required status checks turn CI from advisory into enforced

A CI pipeline that runs but is not marked as a required status check on the default branch is advisory: a red X on a pull request is visible, but nothing actually prevents merging past it. Branch protection rules that name specific job names as required checks are what turn "the tests should pass" into "the tests must pass," and the distinction only matters the day someone is in a hurry.

# Job names here must match EXACTLY what's configured as required
# in the repository's branch protection settings — a renamed job silently
# stops being enforced until the branch protection rule is updated too.
jobs:
  lint:
    name: lint
  test:
    name: test
  typecheck:
    name: typecheck

A required check tied to a job name rather than a workflow file name survives a workflow being split, renamed, or moved to a different file — but only if the job name itself never changes, which is worth treating as a stable, deliberate identifier rather than incidental naming.

  • Require checks to pass on the exact commit being merged, not a stale run from an earlier push to the same branch — most CI providers offer this as a branch protection option worth enabling.
  • A required check that is flaky (fails intermittently for reasons unrelated to the code) trains a team to bypass or re-run it blindly, which defeats its purpose just as thoroughly as not having it at all.
  • Review requirements and required checks are independent settings — both matter, and it is easy to configure one while forgetting the other.

Treating the CI configuration itself as code worth testing

A workflow YAML file is still code, and a change to it — a mistyped path filter, a broken cache key — usually only surfaces the next time a matching PR is opened, often days later and disconnected from the commit that actually broke it. Running act locally to execute a workflow against a sample event, or simply opening a throwaway PR that intentionally touches each filtered path once, catches a broken filter before it silently stops testing a whole app for a week.

Treating the CI config with the same review scrutiny as application code — not rubber-stamping a YAML diff because "it is just config" — is what keeps the pipeline itself trustworthy as the monorepo and its filters grow more complex over time.

CI minutes are a real, metered cost on most platforms, and a pipeline that reruns an entire test suite on every force-push during active development burns through that budget quickly. Cancelling in-progress runs automatically when a new commit lands on the same branch (concurrency: { group: ..., cancel-in-progress: true }) is a small configuration change that keeps CI spend proportional to commits that actually matter, rather than every intermediate push during an active debugging session.

Conclusion

A monorepo pipeline stays fast by caching what does not change, building only what a diff actually touches while still respecting the dependency graph, and running checks in parallel instead of one long sequential script. None of this requires exotic tooling — a well-structured GitHub Actions workflow gets most teams the whole way there.

Member discussion

Share your thoughts with the ToshStack community.

Join the discussion

Become a member of ToshStack to start commenting.

Already a member? Sign in