UK
HomeProjectsBlogAboutContact
Uğur Kaval

AI/ML Engineer & Full Stack Developer building innovative solutions with modern technologies.

Quick Links

  • Home
  • Projects
  • Blog
  • About
  • Contact

Connect

GitHubLinkedInTwitterEmail
Download CV →RSS Feed

© 2026 Uğur Kaval. All rights reserved.

Built with Next.js 16, TypeScript, Tailwind CSS & Prisma

  1. Home
  2. Blog
  3. Scaling Monorepo CI/CD: How I Reduced GitHub Actions Costs by 70%
Automation

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.

June 14, 2026
5 min read
By Uğur Kaval
GitHub ActionsMonorepoDevOpsCI/CDAutomation
Scaling Monorepo CI/CD: How I Reduced GitHub Actions Costs by 70%

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.

Enjoyed this article?

Share it with your network

Uğur Kaval

Uğur Kaval

AI/ML Engineer & Full Stack Developer specializing in building innovative solutions with modern technologies. Passionate about automation, machine learning, and web development.

Related Articles

Scaling Monorepo CI/CD: Patterns that Saved Us 40% in GitHub Actions Costs
Automation

Scaling Monorepo CI/CD: Patterns that Saved Us 40% in GitHub Actions Costs

April 15, 2026

CI/CD Pipeline Patterns for Monorepos with GitHub Actions (2026 Edition)
Automation

CI/CD Pipeline Patterns for Monorepos with GitHub Actions (2026 Edition)

April 3, 2026

Stop Manual DB Changes: A Senior Engineer's Guide to Schema Evolution in 2026
Automation

Stop Manual DB Changes: A Senior Engineer's Guide to Schema Evolution in 2026

June 6, 2026