kernel_panic

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.

Need help with your infrastructure?

We've been solving problems like these for 18+ years. Let's talk about how we can help your team.