# GitHub Actions for Monorepos: Fast CI Without Pipeline Chaos

URL: https://www.devopsness.com/blog/github-actions-monorepo-fast-ci-2026-02-24
Author: Kiril Urbonas
Published: 2026-02-24
Updated: 2026-09-14
Categories: DevOps
Tags: GitHub Actions, CI/CD, Monorepo, DevOps, Automation

Summary: A docs-only PR that waits behind three unrelated backend changes for 35 minutes is not a caching problem. It is a missing ownership boundary.

---
# GitHub Actions for Monorepos: Fast CI Without Pipeline Chaos

A monorepo we worked on grew from 8 services to 27 deployable apps over about a year, and the pipeline never changed shape to match. Every pull request still triggered the same matrix of tests, Docker builds, lint jobs, and preview environments, regardless of what actually changed. The breaking point was a docs-only PR that sat in queue behind three unrelated backend changes and still spent 35 minutes in CI. People started batching risky changes together just to avoid the wait, which made review harder and rollouts more stressful at exactly the size where they needed to get safer.

Monorepos are genuinely good for shared ownership and cross-service refactoring. The tax is that CI gets slow and expensive fast when every job runs on every PR regardless of what it touches. The fix is not a bigger runner. It is a build contract: targeted execution, predictable caching, and deploy gates that only apply to what actually changed.

## The three-layer pattern

Every workflow that scales past a handful of services ends up with the same shape: change detection decides what was touched, a targeted matrix runs only the affected jobs, and a protected deploy stage runs only from trusted branches and environments. Skipping the first layer is the mistake that costs the most, because it is the one that makes the other two irrelevant. A perfectly cached build job is still wasted work if nothing needed to run.

## Detect what actually changed

```yaml
name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.set.outputs.services }}
    steps:
      - uses: actions/checkout@v4
      - id: set
        run: |
          CHANGED=$(git diff --name-only origin/main...HEAD | cut -d/ -f1,2 | sort -u)
          SERVICES=$(echo "$CHANGED" | grep '^services/' | cut -d/ -f2 | jq -R -s -c 'split("\n")[:-1]')
          echo "services=$SERVICES" >> $GITHUB_OUTPUT
```

This keeps a change to docs or an unrelated folder from building applications that were never touched. `dorny/paths-filter` is the more maintainable version of the same idea once you have more than a handful of services, since it lets you declare each service's dependency footprint explicitly instead of parsing diffs by hand:

```yaml
jobs:
  detect_changes:
    runs-on: ubuntu-latest
    outputs:
      api_changed: ${{ steps.filter.outputs.api }}
      web_changed: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - "apps/api/**"
              - "packages/shared/**"
            web:
              - "apps/web/**"
              - "packages/shared/**"

  api_checks:
    needs: detect_changes
    if: needs.detect_changes.outputs.api_changed == 'true'
    uses: ./.github/workflows/service-checks.yml
    with:
      service_path: apps/api
```

The shared-package pattern matters more than it looks: a change to `packages/shared` should trigger every service that depends on it, not just the folder that literally changed. Missing this is how a "fast" pipeline quietly stops testing things that actually broke.

## Run only the affected matrix

```yaml
  test:
    needs: detect
    if: ${{ needs.detect.outputs.services != '[]' }}
    strategy:
      matrix:
        service: ${{ fromJson(needs.detect.outputs.services) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint --workspace=services/${{ matrix.service }}
      - run: npm run test --workspace=services/${{ matrix.service }}
```

Fail fast here matters as much as the targeting does. Lint and type checks should block before the heavier integration suite starts, not run in parallel with it, so a trivial mistake gets reported in seconds instead of after a ten-minute build.

## Gate the deploy, not just the build

```yaml
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
```

Pair this with protected environments and required manual approval for production. This is also where reusable workflows earn their keep: keeping each service's checks in a small, explicit workflow file means new engineers, auditors, and on-call responders can read exactly what a given service's pipeline owns without reconstructing it from a 400-line monolith.

## What actually moved the number

Larger runners were the first thing tried, and they barely helped, because the problem was never raw compute. What worked was reworking the pipeline around dependency-aware paths, splitting fast confidence checks (lint, type checks, unit tests) from slower service-specific builds, and tracking queue time and cache hit rate rather than just total workflow duration. Watching only total duration hides the actual failure mode: a fast pipeline that is fast for the wrong jobs and still slow for the one PR that matters.

## The decision, concretely

- Every PR still runs every job regardless of what changed? Add path-based change detection first. It is the single change that pays off the most and everything else compounds on top of it.
- Shared packages exist that multiple services depend on? Make sure your path filters trigger downstream services on shared-package changes, not just the folder that changed.
- Cache hit rate is good but the pipeline still feels slow? Check whether lint and unit tests are gating the heavy build, or running in parallel with it and hiding a fast failure behind a slow one.
- Growing past roughly 15 services? Move from ad-hoc `if` conditions to `dorny/paths-filter` and reusable workflows before the workflow file itself becomes the maintenance burden.

## The call we'd make

Do not reach for a bigger runner before you have change detection. The teams that fix monorepo CI durably are not the ones who cut a few minutes off a build; they are the ones who gave each service an explicit ownership boundary in the pipeline itself, so a docs change costs seconds and a real change still gets full coverage. That is also what keeps main trustworthy as the repo grows past the size where anyone can hold the whole dependency graph in their head.

