GitHub Actions for Monorepos: Fast CI Without Pipeline Chaos
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.
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
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:
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
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
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
ifconditions todorny/paths-filterand 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.
Azure DevOps Best Practices in 2026: Build Pipelines You Can Trust
A production-focused, example-rich guide to Azure DevOps: template-driven YAML, immutable artifact promotion, secure OIDC service connections, environment approvals, canary rollouts with automatic rollback, IaC governance, and DORA-driven delivery reliability.
Platform Engineering with Backstage: Build a Useful Developer Portal
How to implement Backstage with real templates, scorecards, and golden paths so internal platform work reduces delivery friction.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.