Scaling Monorepo CI/CD: How I Reduced GitHub Actions Costs by 70%
Stop building everything. Learn the exact GitHub Actions patterns I use to manage 50+ services in a single repository without losing my mind or my budget.

The Monorepo CI/CD Nightmare
My team's CI bill hit $4,000 last month because a single README change triggered 42 parallel Docker builds. If your monorepo pipeline takes longer than 10 minutes, you're not scaling—you're just waiting for a bottleneck to break your release cycle. In 2026, we shouldn't be building code that hasn't changed, yet I see senior engineers still using basic path-filtering that breaks branch protection rules. The friction between monorepo structure and CI efficiency is one of the most expensive technical debts you can carry.
Why Selective Execution is Your First Priority
In a standard repository, on: push: paths: works fine. In a monorepo with 50+ services, it’s a disaster. Why? Because GitHub’s required status checks don’t understand 'skipped' jobs. If service-a is required for merge but its workflow didn't trigger because you only touched service-b, your PR is stuck in limbo. You end up either disabling required checks (dangerous) or running everything (expensive).
To solve this, we use the Orchestrator Pattern. Instead of multiple workflows, we use a single entry-point workflow that determines what needs to run and then triggers the appropriate jobs. This ensures that the 'status check' always reports back, even if it’s just to say 'nothing to do'.
Pattern 1: The Orchestrator and Path Filtering
We use dorny/paths-filter to detect changes. It’s significantly more flexible than the native implementation because it allows us to map changes to outputs that can be consumed by subsequent jobs. This is the foundation of a 'smart' pipeline that understands your dependency graph.
name: CI Orchestrator
on:
pull_request:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
apps: ${{ steps.filter.outputs.changes }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api: ['apps/api/**', 'libs/shared/**']
web: ['apps/web/**', 'libs/shared/**']
billing: ['apps/billing/**', 'libs/shared/**']
infra: ['terraform/**']
Notice the `libs/shared/**` inclusion. This is where most teams fail. If your API depends on a shared library, the API tests must run when the library changes. By defining these relationships in the filter, you automate the dependency graph without needing complex tooling like Nx if your team isn't ready for that overhead yet.
## Pattern 2: Dynamic Matrix Strategy
Once we know which apps changed, we don't want to define 50 separate jobs in our YAML. That leads to a maintenance nightmare. We use a dynamic matrix. This pattern allows us to scale to hundreds of services without ever touching our YAML file again. The matrix is fed directly by the output of our filter step.
```yaml
build-and-test:
needs: changes
if: ${{ needs.changes.outputs.apps != '[]' && needs.changes.outputs.apps != '' }}
strategy:
matrix:
app: ${{ fromJson(needs.changes.outputs.apps) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and Test ${{ matrix.app }}
run: |
cd apps/${{ matrix.app }}
npm run build
npm run test
In 2026, we’ve moved beyond simple scripts. This pattern integrates perfectly with **OIDC (OpenID Connect)**. Instead of storing long-lived secrets, each matrix job can request a short-lived token from AWS or GCP to push its specific container image. This limits the blast radius: if the API build job is compromised, it only has permissions for the API container registry, not the entire infrastructure.
## Pattern 3: Centralized Reusable Workflows
Don't repeat your Docker build logic 50 times. We use `workflow_call` to maintain a single source of truth for our deployment standards. This allows the platform team to update security scanning tools (like Trivy) or base images in one place and have it propagate to every service instantly. A reusable workflow for a monorepo should be generic enough to handle different entry points but strict enough to enforce company-wide security policies.
### The 'Always-Pass' Gatekeeper Check
To satisfy GitHub's required status checks, you need a final job that depends on all others. This is the 'Gatekeeper' pattern. This job always runs, but its success depends on the status of the dynamic jobs. This is the only way to have branch protection and selective execution coexist.
```yaml
check-ci-success:
needs: [build-and-test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Verify CI Status
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: exit 1
You set *this* job as your required status check in GitHub settings. If no apps changed, the `build-and-test` job is skipped, `check-ci-success` runs, sees no failures, and exits 0. Your PR is now mergeable without wasting a single CI minute.
## Gotchas: What the Documentation Doesn't Tell You
1. **The Ghost Merge Conflict**: When using `actions/checkout`, the default is a shallow clone (`fetch-depth: 1`). If your path-filtering logic needs to compare the current branch against `main` to find changed files, a shallow clone might not have enough history to find the common ancestor. Always set `fetch-depth: 0` on the orchestrator job to ensure the git diff is accurate.
2. **Concurrency Limits**: GitHub Actions has a limit on concurrent jobs (e.g., 180 for Enterprise). If you trigger 50 apps at once and each has 3 matrix components, you'll hit that limit and queue every other developer in the company. Always use `concurrency` groups at the PR level to cancel outdated builds when a dev pushes a new commit to the same branch.
3. **Cache Bloat**: In a monorepo, the global cache can quickly reach the 10GB limit. Use specific cache keys like `${{ runner.os }}-${{ matrix.app }}-${{ hashFiles('**/package-lock.json') }}` to ensure that changing a dependency in `app-a` doesn't invalidate the cache for `app-b`. If you're using Turborepo or Nx, prefer remote caching (S3/GCS) over the local GitHub cache for better performance.
## Takeaway
Stop building everything. Today, audit your CI logs to find which jobs are running on every commit despite no changes. Implement the `dorny/paths-filter` Orchestrator pattern and consolidate your required status checks into a single 'Gatekeeper' job. This shift alone will reduce your CI minutes by at least 50% and eliminate the 'stuck PR' issue that plagues most monorepo teams.