GitHub Actions Advanced Patterns: Matrix Builds, Reusable Workflows, and Self-Hosted Runners

Beyond Basic CI: Where GitHub Actions Gets Interesting

If you've been using GitHub Actions for a while, you've probably outgrown the "push to main, run tests" setup. The real power shows up when you start compositing workflows — matrix builds that test across multiple runtimes, reusable workflows that eliminate copy-paste between repos, and self-hosted runners for the stuff GitHub's machines can't handle.

I've spent the last two years managing Actions pipelines for a monorepo with 40+ services. Here's what actually works at scale.

Matrix Builds That Don't Waste Money

A basic matrix strategy is straightforward — you define axes and Actions runs every combination. But the naive approach burns through minutes fast.

jobs:
  test:
    strategy:
      fail-fast: true
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

The fail-fast: true flag is worth remembering. If your Node 18 build fails, there's usually no point running 20 and 22 — you'll save 4-6 minutes of billed time per failure. For open-source projects on the free tier, that's the difference between hitting your monthly limit or not.

Dynamic matrices take this further. Instead of hardcoding values, you can generate the matrix from a previous job's output:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api: 'services/api/**'
            web: 'services/web/**'
            worker: 'services/worker/**'

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.services != '[]'
    strategy:
      matrix:
        service: ${{ fromJson(needs.detect-changes.outputs.services) }}
    runs-on: ubuntu-latest
    steps:
      - run: cd services/${{ matrix.service }} && make test

This pattern cuts CI time by 60-80% in monorepos. If you only touched the API service, why run tests for web and worker?

Reusable Workflows: DRY for CI

Copy-pasting workflow files across 15 repositories is a maintenance nightmare. Reusable workflows fix this — you define a workflow once in a central repo and call it from everywhere else.

The caller looks like this:

jobs:
  deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@v2
    with:
      environment: production
      service-name: api
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

A few things I've learned the hard way about reusable workflows:

  • Pin to tags, not branches. Using @main means any change to the shared workflow immediately affects every downstream repo. Use semantic version tags like @v2 so teams can upgrade on their own schedule.
  • You can nest reusable workflows up to 4 levels deep. In practice, two levels is the sweet spot — a top-level orchestration workflow calling specialized sub-workflows.
  • The secrets: inherit shorthand passes all caller secrets to the reusable workflow. Convenient, but it means the shared workflow has access to secrets it might not need. I'd argue explicit secret passing is worth the extra lines for security-sensitive pipelines.
  • Reusable workflows can't be triggered by workflow_dispatch directly — they're always called from another workflow. If you need manual triggers, add a thin wrapper.

Composite Actions vs Reusable Workflows

These get confused constantly. Composite actions are step-level reuse — they bundle a sequence of steps into a single action. Reusable workflows are job-level reuse — they define entire jobs with their own runner context.

Rule of thumb: if the reusable piece needs its own runs-on or services (like a database container), use a reusable workflow. If it's just a set of steps that run on the caller's runner, composite action.

Self-Hosted Runners: When GitHub's Machines Don't Cut It

GitHub-hosted runners are great until they aren't. The standard ubuntu-latest comes with 4 vCPUs and 16 GB RAM — fine for most test suites, but try running integration tests with Elasticsearch, PostgreSQL, and Redis all in Docker-in-Docker. You'll hit memory limits fast.

Self-hosted runners give you:

  • Custom hardware specs (our build machine has 32 cores and 128 GB RAM)
  • Persistent caches that don't need re-downloading every run
  • Access to internal networks without VPN tunnels
  • GPU access for ML workloads

The security model is the biggest consideration. Self-hosted runners on public repos are dangerous — any forked PR can run arbitrary code on your machine. Stick to private repos or use the --ephemeral flag that spins up a fresh runner per job.

For teams running Kubernetes, the Actions Runner Controller (ARC) autoscales runners as pods. It watches for queued jobs and provisions runners on demand, scaling to zero when idle. We cut our runner costs by 70% switching from always-on EC2 instances to ARC on an existing EKS cluster.

Runner Groups and Labels

Organize runners with labels so workflows target the right hardware:

jobs:
  gpu-test:
    runs-on: [self-hosted, gpu, linux]
    steps:
      - run: nvidia-smi
      - run: python train_model.py --quick-check

  standard-test:
    runs-on: [self-hosted, linux, x64]
    steps:
      - run: make test

Runner groups add an organization layer — you can restrict which repos can use expensive GPU runners, preventing surprise bills.

Workflow Optimization Tricks

A few patterns that consistently shave minutes off pipelines:

Concurrency groups cancel in-progress runs when a new push arrives on the same branch. No point finishing a build for commit abc123 when def456 just landed:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Dependency caching with actions/cache is obvious but often misconfigured. Hash your lock file, not your package.json — and remember that cache entries expire after 7 days of no access.

Job-level permissions follow least-privilege. Don't give every job write access to everything when only the deploy job needs it:

permissions:
  contents: read
  packages: write

Honestly, most slow CI pipelines aren't slow because of GitHub Actions itself. They're slow because of what's running inside them — bloated Docker images, missing caches, tests that hit real databases when they don't need to. Fix those first before reaching for bigger runners.