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@v4notactions/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_dispatchwithout constraints. Manual triggers are useful for hotfixes but should not bypass required checks. - Ignoring job timeouts. Set
timeout-minuteson 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.





