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
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
| Situation | Approach |
|---|---|
| Testing multiple Node/Python/Java versions | Matrix strategy with version array |
| Same pipeline across 5+ repos | Reusable workflow in a shared repo |
| Staging → Production deployment | Reusable workflow with environment input |
| Expensive long-running matrix | Use 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.
