A monorepo starts feeling like a great idea — one repo, shared libraries, unified tooling. Then your CI pipeline kicks in and rebuilds every service because someone updated a README in the docs/ folder. Five minutes of wasted compute later, you are questioning every architectural decision you have ever made.

The good news: GitHub Actions has the primitives to fix this. With path-based filters, workflow-level caching, and targeted deployment jobs, you can build a pipeline that only touches what actually changed. This guide walks through a practical setup for a SaaS monorepo containing multiple backend services, a frontend app, and shared packages.


Understanding the Monorepo Layout

Assume a structure like this:

/
├── apps/
│   ├── api/          # Node.js REST API
│   ├── web/          # Next.js frontend
│   └── worker/       # Background job service
├── packages/
│   ├── ui/           # Shared React component library
│   └── utils/        # Shared utility functions
├── .github/
│   └── workflows/
└── package.json      # Root workspace config (npm/yarn/pnpm workspaces)

Each apps/* service is independently deployable. The packages/* directories are internal libraries consumed by one or more apps. A change to packages/utils should trigger CI for every app that imports it — but a change to apps/api alone should leave apps/web and apps/worker untouched.


Step 1: Use Path Filters to Scope Triggers

GitHub Actions supports the paths key on push and pull_request triggers. Combine this with separate workflow files per service.

Create .github/workflows/api.yml:

name: API — CI & Deploy

on:
  push:
    branches: [main]
    paths:
      - "apps/api/**"
      - "packages/utils/**"
      - "packages/ui/**"
  pull_request:
    paths:
      - "apps/api/**"
      - "packages/utils/**"
      - "packages/ui/**"

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run API tests
        run: pnpm --filter api test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy API
        run: ./scripts/deploy-api.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Repeat the pattern for web.yml and worker.yml, adjusting the paths and filter commands accordingly.

Why separate workflow files instead of one giant workflow?

A single monolithic workflow file with conditional steps becomes hard to read and maintain fast. Separate files give you independent run histories, cleaner failure notifications, and the ability to re-run just the failing service without triggering unrelated jobs.


Step 2: Cache Dependencies Efficiently Across Jobs

Dependency installation is often the biggest time sink in a Node.js monorepo. GitHub Actions' cache action (and the built-in cache option on actions/setup-node) stores the pnpm or npm cache keyed to your lockfile hash.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: "pnpm"

For a workspace that runs multiple parallel jobs — say, linting, testing, and type-checking — add a dedicated install job that runs first, then share the cache across downstream jobs using a cache key based on hashFiles('**/pnpm-lock.yaml'). This avoids running pnpm install redundantly in every parallel job.

The key insight: cache hits are fast (seconds), but cache misses on large dependency trees can add two to four minutes per job. Design your key strategy so that lockfile-stable runs always hit cache.


Step 3: Build a Reusable Workflow for Shared Logic

If your CI steps are near-identical across services — lint, type-check, test, build — extract them into a reusable workflow at .github/workflows/service-ci.yml using workflow_call.

on:
  workflow_call:
    inputs:
      service:
        required: true
        type: string
    secrets:
      DEPLOY_TOKEN:
        required: true

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter ${{ inputs.service }} lint
      - run: pnpm --filter ${{ inputs.service }} test
      - run: pnpm --filter ${{ inputs.service }} build

Each service workflow then becomes a thin caller:

jobs:
  ci:
    uses: ./.github/workflows/service-ci.yml
    with:
      service: api
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

This approach enforces consistency. When you update the shared CI logic — say, adding a security audit step — it propagates to every service automatically.


Step 4: Handle Shared Package Changes Correctly

When packages/utils changes, every downstream service needs to re-run CI. Rather than duplicating that path in every workflow file and risking drift, use a change detection job powered by dorny/paths-filter:

- name: Detect changed paths
  uses: dorny/paths-filter@v3
  id: filter
  with:
    filters: |
      api:
        - "apps/api/**"
        - "packages/utils/**"
      web:
        - "apps/web/**"
        - "packages/ui/**"
        - "packages/utils/**"

Downstream jobs then gate on if: steps.filter.outputs.api == 'true'. This centralizes dependency mapping in one place and makes the "what triggers what" logic explicit and auditable.


Step 5: Per-Service Deployment Jobs With Environment Protection

Deployments should be gated. GitHub Environments let you require manual approvals for production, store environment-scoped secrets, and maintain a deployment history per environment.

Define environments in your repo settings (api-production, web-production, etc.), then reference them in deployment jobs:

deploy-api:
  needs: test
  if: github.ref == 'refs/heads/main'
  environment: api-production
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to production
      run: ./scripts/deploy-api.sh

For staging environments, remove the manual approval gate and let deployments run automatically on every push to main. Production promotions require a human sign-off. This gives SaaS teams a clean preview-then-promote flow without adding a third-party CD tool.


Common Pitfalls to Avoid

  • Not pinning action versions. Use actions/checkout@v4 not actions/checkout@main. Unpinned actions are a supply chain risk.
  • Storing secrets in workflow files. Always use ${{ secrets.YOUR_SECRET }}. Never hardcode credentials.
  • Over-triggering on workflow_dispatch without constraints. Manual triggers are useful for hotfixes but should not bypass required checks.
  • Ignoring job timeouts. Set timeout-minutes on long-running jobs. A hung test suite should not consume your Actions minutes budget indefinitely.

Why This Matters for Your Project

Shipping a SaaS product on a monorepo architecture is a legitimate scaling strategy — it simplifies dependency management and keeps your codebase coherent as your team grows. But the CI/CD layer has to match that architecture, or you end up with slow feedback loops that punish developers for every small change. A well-structured GitHub Actions setup with path filters, shared caching, and per-service deployment gates turns your pipeline from a bottleneck into a force multiplier — letting your team merge confidently and deploy incrementally without ceremony.