GitHub·7 min read·

Matrix Strategy in GitHub Actions: Multiple OSs and Versions

Run several operating systems and runtime-version combinations with the GitHub Actions matrix strategy, plus include, exclude, fail-fast and parallel limits.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

Testing against one Node version on one OS is how subtle portability bugs slip through. GitHub Actions solves this with the matrix strategy: a single job definition expanded into many parallel variants, each a different combination of the named factors. One matrix entry can test three OSs times four Node versions, and GitHub fans each combination out as its own runner. This post covers matrix basics, when to include and exclude, and the performance pitfalls.

The core idea

A matrix is defined under jobs.<name>.strategy.matrix. You list factors with arrays of values, and GitHub builds the Cartesian product — every combination of one value from each factor. Each combination becomes a separate job instance with a unique index. Within the job, the current combination is available through the matrix context.

The most common factors are operating systems and runtime versions. Here is a Node testing matrix:

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

That definition produces nine parallel jobs (three OSs times three Node versions), each running the same steps with a different combination. The matrix context gives you matrix.os and matrix.node to parameterize actions, cache keys, and artifact names so they do not collide.

Carrying matrix values into later phases

A common need is to build on the matrix and then upload results keyed by the combination. Use matrix values inside artifact names or in a later job that references the matrix output:

yaml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-22.04, ubuntu-24.04]
        node: [18, 20]
    steps:
      - run: npm test
      - uses: actions/upload-artifact@v4
        with:
          name: test-report-${{ matrix.os }}-${{ matrix.node }}
          path: test-report.xml

Matrix-driven artifact names let a later job download the right one per combination, or you can aggregate them. Keep the names unique — the matrix values are the natural key.

include, exclude and the eager matrix

GitHub lets you refine the product. exclude removes specific combinations that do not make sense (for example, skip Node 22 on an OS that does not support it yet). include adds extra jobs that are not part of the pure product, or merges extra fields onto matching combinations. include is subtle: when you include an object with a unique matrix key, it becomes an extra root job; when it matches an existing combination, it adds its extra fields to that job.

A common use of include is to attach a framework flag to a specific combination. Here is a matrix with an exclude and an include:

yaml
strategy:
  matrix:
    os: [ubuntu-22.04, ubuntu-24.04, macos-latest]
    node: [18, 20]
    exclude:
      - os: macos-latest
        node: 18
    include:
      - os: ubuntu-22.04
        node: 20
        experimental: true

Fail-fast and max-parallel

By default, when one matrix job fails, GitHub cancels all the in-flight matrix jobs — that is fail-fast: true. If you want a job to keep running even when a sibling fails, set fail-fast: false at the strategy level. This is valuable when a matrix is doing independent reporting and one failure should not abort the rest. You can also cap concurrency with max-parallel to avoid hammering resources.

yaml
strategy:
  fail-fast: false
  max-parallel: 4
  matrix:
    os: [ubuntu-22.04, ubuntu-24.04]
    node: [18, 20, 22]

Pitfalls to watch

Three pitfalls deserve attention. First, matrix jobs are independent runners — they re-checkout and re-install, so total time is not reduced by a single save; it is distributed across parallel machines. Second, the matrix product grows fast: three factors of four values each is 64 jobs. Keep factors aligned to real compatibility needs, and use exclude generously. Third, if a later job must consume the matrix results (like an aggregate report), give the matrix job outputs and read them with needs, or upload artifacts with unique names. And remember that self-hosted runners can be part of a matrix too — but only mark the runner if it is actually available for those labels, or the job will sit in the queue.

one job def matrix: os x node N parallel ubuntu x node18 ubuntu x node20 win x node18 win x node20 report: os-node report: os-node include / exclude / fail-fast / max-parallel each combo = independent runner + fresh checkout

Takeaway

Matrices give you confidence that code really works across platforms and versions, at the cost of parallel resources. Keep factors to real compatibility needs, use exclude and include to prune nonsense, and let fail-fast match how much you want individual failures to block the fleet. When you want eyeballs on a matrix, the parallel runners on a Netbay Ubuntu VPS are a cheap way to add capacity — spin one up in under a minute at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance