2025-03-01 · 6 min read
How to Set Up GitHub Actions for a Monorepo (With Path Filtering and Reusable Workflows)
A practical, battle-tested guide to configuring GitHub Actions for monorepos — path-based triggers, reusable workflows, shared caching, and the gotchas nobody warns you about.
The Monorepo CI Problem
You've got a monorepo. Maybe it's three services and a shared library. Maybe it's twelve microservices, two frontends, and a collection of Terraform modules. Either way, you've hit the same wall everyone hits: your CI pipeline builds and tests everything on every push, and it's painfully slow.
We've set up GitHub Actions for monorepos ranging from a handful of services to well over fifty. The patterns here are what we keep coming back to because they actually work at scale.
Step 1: Path-Based Workflow Triggers
The most important thing you can do is stop running workflows for code that didn't change. GitHub Actions supports path filtering natively with the paths key:
# .github/workflows/api-service.yml
name: API Service CI
on:
push:
branches: [main]
paths:
- 'services/api/**'
- 'libs/shared/**'
- '.github/workflows/api-service.yml'
pull_request:
paths:
- 'services/api/**'
- 'libs/shared/**'
- '.github/workflows/api-service.yml'
jobs:
build-and-test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Run tests
working-directory: services/api
run: |
npm ci
npm test
A few things to notice here. First, include your shared libraries in the path filter. If services/api depends on libs/shared, changes to that library need to trigger the API workflow. Second, include the workflow file itself in the paths list. If you change the CI config, you want it to actually run. People forget this one constantly.
Step 2: Reusable Workflows for Shared Logic
When you have ten services that all follow the same build-test-deploy pattern, you don't want ten copies of the same workflow with minor variations. Reusable workflows solve this:
# .github/workflows/service-ci-template.yml
name: Service CI Template
on:
workflow_call:
inputs:
service-name:
required: true
type: string
working-directory:
required: true
type: string
node-version:
required: false
type: string
default: '20.11.0'
secrets:
NPM_TOKEN:
required: false
jobs:
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
working-directory: ${{ inputs.working-directory }}
- run: npm run lint
working-directory: ${{ inputs.working-directory }}
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
working-directory: ${{ inputs.working-directory }}
- run: npm test
working-directory: ${{ inputs.working-directory }}
Then each service's workflow becomes tiny:
# .github/workflows/api-service.yml
name: API Service CI
on:
push:
branches: [main]
paths:
- 'services/api/**'
- 'libs/shared/**'
pull_request:
paths:
- 'services/api/**'
- 'libs/shared/**'
jobs:
ci:
uses: ./.github/workflows/service-ci-template.yml
with:
service-name: api
working-directory: services/api
secrets: inherit
This is a massive maintenance win. When you need to update the Node version or add a security scanning step, you do it once in the template.
Step 3: Smart Dependency Caching
Caching in a monorepo needs more thought than in a single-project repo. The key is scoping your cache keys properly so each service gets cache hits without stomping on each other:
- uses: actions/cache@v4
with:
path: |
~/.npm
${{ inputs.working-directory }}/node_modules
key: ${{ runner.os }}-${{ inputs.service-name }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
restore-keys: |
${{ runner.os }}-${{ inputs.service-name }}-
The restore-keys fallback is important. If the lock file changed but most dependencies are the same, you'll still get a partial cache hit instead of a full cold install.
For Docker image builds in a monorepo, layer caching is even more valuable. Use the docker/build-push-action with GitHub Actions cache backend:
- uses: docker/build-push-action@v5
with:
context: services/api
push: true
tags: your-registry/api:${{ github.sha }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
Scoping the cache by service name prevents your API service build from evicting cache layers that belong to your billing service.
Step 4: Handling Cross-Service Dependencies
The trickiest part of monorepo CI is when services depend on each other. You have two good options.
Option A: Build everything that changed in dependency order. Use a dynamic matrix and a change-detection step:
jobs:
detect-changes:
runs-on: ubuntu-22.04
outputs:
services: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
- 'libs/shared/**'
web:
- 'services/web/**'
- 'libs/shared/**'
billing:
- 'services/billing/**'
- 'libs/shared/**'
build:
needs: detect-changes
if: ${{ needs.detect-changes.outputs.services != '[]' }}
strategy:
matrix:
service: ${{ fromJson(needs.detect-changes.outputs.services) }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Build ${{ matrix.service }}
working-directory: services/${{ matrix.service }}
run: npm ci && npm run build
The dorny/paths-filter action is excellent for this. It gives you a JSON array of which services changed, and you feed that directly into a matrix strategy.
Option B: Publish shared libraries as packages. If your shared code is in libs/shared, publish it to a private npm registry (or GitHub Packages) and have services consume it as a normal dependency. This decouples the build process at the cost of a more complex release process for the library.
We generally recommend Option A for monorepos with under twenty services and Option B when things get larger.
Common Gotchas
Workflow run limits. GitHub Actions has a limit of 20 concurrent jobs on the free tier and 500 on enterprise. In a large monorepo, a change to a shared library can trigger dozens of workflows simultaneously. Keep an eye on queue times and consider self-hosted runners if you're hitting limits.
The paths filter doesn't work on the default branch's first push. If you create a new workflow file with path filters and push it, it will run on that push regardless of which paths changed. This is by design. Subsequent pushes will filter correctly.
Pull request workflows and fork security. Workflows triggered by pull_request from a fork won't have access to your secrets. If your tests need secrets (like a database connection string for integration tests), use pull_request_target carefully or run those tests only on merge to main.
Matrix strategy and path filters don't compose automatically. You can't put path filters inside matrix entries natively. The dorny/paths-filter approach above is the standard workaround.
A Real-World Structure
Here's the directory layout we typically recommend:
.github/
workflows/
service-ci-template.yml # Reusable template
api-service.yml # Thin wrapper per service
web-service.yml
billing-service.yml
deploy-template.yml # Reusable deploy template
deploy-production.yml # Triggered on main merge
services/
api/
web/
billing/
libs/
shared/
ui-components/
infrastructure/
terraform/
Each service gets its own thin workflow file that calls the reusable template. The template contains all the real logic. Changes to shared libraries trigger all dependent service workflows through path filters.
This structure has saved our clients hundreds of hours of CI time per month. It's not glamorous, but it works.
Running a monorepo and struggling with CI performance? We've tuned GitHub Actions setups for teams of all sizes. Reach out and let's fix it together.