GitHub Actions Matrix Builds and Reusable Workflows

How matrix strategies and reusable workflows in GitHub Actions keep CI/CD maintainable as projects grow.

Jean-Pierre Broeders

Freelance DevOps Engineer

March 5, 20266 min. read

Matrix Builds and Reusable Workflows in GitHub Actions

Setting up a pipeline for a single project is straightforward. But what happens when ten repositories need slightly different build configurations? Or a library needs testing on Node 18, 20, and 22? Copy-pasting workflow files becomes a maintenance headache fast.

GitHub Actions has two features that solve this: matrix strategies and reusable workflows. Together they keep pipelines DRY without sacrificing readability.

Matrix Strategy: One Job, Multiple Configurations

A matrix strategy runs the same job with different variable combinations. Simple example for a Node.js project:

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

This generates six jobs (3 versions × 2 operating systems). Each combination runs in parallel, keeping total execution time low.

Excluding Specific Combinations

Not every combination makes sense. Say Windows + Node 18 isn't supported:

strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]
    exclude:
      - node-version: 18
        os: windows-latest

Or add extra combinations with include:

strategy:
  matrix:
    node-version: [18, 20, 22]
    include:
      - node-version: 22
        experimental: true
  fail-fast: false

That fail-fast: false matters. By default, GitHub Actions cancels all matrix jobs the moment one fails. For testing, seeing all results is usually more useful than just the first failure.

Reusable Workflows: Define Once, Use Everywhere

Matrix builds help within a single repo. But when five repositories share the same deploy steps, reusable workflows are the answer.

A reusable workflow is a regular workflow file with workflow_call as its trigger:

# .github/workflows/deploy-template.yml
name: Deploy Template

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      DEPLOY_TOKEN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci && npm run build
      - run: |
          curl -X POST "https://api.deployment.example/deploy" \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
            -d '{"env": "${{ inputs.environment }}"}'

Calling it from another workflow (or a different repo entirely):

jobs:
  deploy-staging:
    uses: my-org/shared-workflows/.github/workflows/deploy-template.yml@main
    with:
      environment: staging
    secrets:
      DEPLOY_TOKEN: ${{ secrets.STAGING_TOKEN }}

  deploy-production:
    needs: deploy-staging
    uses: my-org/shared-workflows/.github/workflows/deploy-template.yml@main
    with:
      environment: production
    secrets:
      DEPLOY_TOKEN: ${{ secrets.PROD_TOKEN }}

Change the template in one place and every repository benefits. No more twenty PRs to update a deploy step.

Combining Matrix + Reusable Workflows

The real power comes from combining both. A reusable workflow can contain its own matrix:

# shared: test-and-deploy.yml
on:
  workflow_call:
    inputs:
      node-versions:
        type: string
        default: '["18","20","22"]'

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ${{ fromJson(inputs.node-versions) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci && npm test

The calling workflow decides which versions to test. Each project can pass its own set without modifying the template.

Practical Tips

SituationApproach
Testing multiple Node/Python/Java versionsMatrix strategy with version array
Same pipeline across 5+ reposReusable workflow in a shared repo
Staging → Production deploymentReusable workflow with environment input
Expensive long-running matrixUse fail-fast: true and max-parallel: 3

A few things that commonly go wrong:

  • Forgetting to pass secrets. Reusable workflows don't automatically inherit secrets from the caller. Every secret needs explicit passing, or use secrets: inherit (though that hands over everything — not always desirable).
  • Version pinning. Reference reusable workflows with a SHA or tag, not @main. Otherwise a push to the shared repo can unexpectedly break all pipelines.
  • Matrix limits. GitHub allows a maximum of 256 jobs per matrix. Sounds generous, but with three dimensions it fills up quicker than expected.

When Not To Bother

For a single project with one target version and a simple deploy? A regular workflow file does fine. Reusable workflows and matrices add complexity that only pays off with multiple repos or configurations. Start simple, refactor when the duplication starts hurting.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch