DevOps · Cicd

GitHub Actions Matrix Strategies

GitHub Actions Matrix Strategies is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and.

John Kihiu12 min read

A build matrix runs the same job definition across a set of variable combinations — Node versions, operating systems, database backends — without copy-pasting the job. It's one of the more underused features in GitHub Actions, partly because the failure modes (one flaky combination blocking the whole matrix, ballooning minutes bills) aren't obvious until they happen.

The basic matrix syntax

strategy.matrix takes one or more variables, each with a list of values, and GitHub Actions runs the job once per combination — the Cartesian product of all the lists. A matrix with 3 Node versions and 2 operating systems produces 6 job runs, each with its own log and pass/fail status in the checks UI.

YAML · BASIC TEST MATRIX
jobs:
  test:
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

include and exclude for shaping the matrix

The full Cartesian product isn't always what you want — some combinations are redundant or invalid. exclude removes specific combinations from the generated set. include does two things: it can add extra standalone combinations not covered by the base lists, and when it matches an existing combination's keys, it adds extra variables to just that entry — useful for marking one combination as the "primary" one that also uploads a coverage report, without duplicating the whole matrix.

YAML · INCLUDE/EXCLUDE EXAMPLE
strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]
    exclude:
      - node-version: 18
        os: windows-latest
    include:
      - node-version: 20
        os: ubuntu-latest
        upload-coverage: true

fail-fast and max-parallel

fail-fast defaults to true, which cancels all other in-progress matrix jobs the moment one fails — good for saving minutes on a clearly broken change, bad when you actually want to see results across every combination (e.g., "does this fail on Windows specifically or everywhere"). Set fail-fast: false when the point of the matrix is diagnostic breadth. max-parallel caps how many matrix jobs run concurrently, which matters on self-hosted runners with limited capacity or when you're intentionally throttling load against a shared resource like a test database.

Turn off fail-fast when debugging cross-platform issues

If a matrix job exists specifically to answer "which combinations break," fail-fast cancelling the rest of the matrix on the first failure defeats the purpose. Set fail-fast: false so every combination reports its own result.

Matrix jobs and minutes cost

Every matrix combination is a full job — full runner boot, full checkout, full dependency install — and GitHub Actions minutes billing (for private repos beyond the free tier) counts each one. A 4x4 matrix isn't 4x or 4x the work of a single job in wall-clock time if they run in parallel, but it is roughly 16x the billed minutes. Keep the matrix scoped to combinations that genuinely need independent verification — testing against every patch version of a runtime rarely finds bugs that testing the majors and the oldest-supported version won't.

A failing matrix job still needs a clear required-check story

If you use branch protection with required status checks, each matrix combination shows up as its own named check. Adding or removing matrix values changes which checks exist, which can silently break required-check configuration on a protected branch if it isn't updated alongside the matrix.

Dynamic matrices generated by a previous job

The matrix values don't have to be hardcoded — a prior job can compute a JSON array (say, the list of changed packages in a monorepo) and expose it as a job output, which a downstream job consumes via fromJSON() in its matrix definition. This is how monorepo pipelines build a test matrix that only includes the packages actually affected by a given push, instead of testing everything on every change.

Wrapping up

Matrix strategy earns its keep when you genuinely need to verify the same logic across multiple real-world variants — runtime versions, OSes, dependency versions. Keep the matrix as small as the actual support surface requires, set fail-fast deliberately rather than by default, and remember every added dimension multiplies billed minutes, not just coverage.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.