GitHub Actions Matrix Builds: A Practical Guide to Multi-Platform Multi-Version Parallel Testing
Last year, an open-source project reached out to me, saying their CI configuration file had ballooned to over 800 lines. I opened it and found it packed with repetitive job definitions—Node 16 on Ubuntu, Node 16 on Windows, Node 16 on macOS… then the same for Node 18, and again for Node 20. Need to change a test command? That’s 12 places to update. Adding a new version? Copy-paste for another ten minutes.
That’s when I realized many people are still manually maintaining multi-version multi-platform tests.
GitHub Actions’ Matrix feature essentially automates this expansion. You define a few operating systems, a few runtime versions, and it automatically runs all combinations for you. Sounds simple, but there are plenty of pitfalls when you actually use it—exploding combination counts leading to billing explosions, one task failure taking down the entire pipeline, not knowing how to exclude specific combinations… I’ve encountered all of these issues.
This article starts with basic Matrix syntax and walks you through advanced techniques: exclude/include for precise control, fail-fast strategy selection, max-parallel concurrency limits, and dynamic Matrix generation. Finally, I’ll give you 5 production-ready workflow templates you can copy directly into your projects, covering scenarios from personal projects to enterprise applications.
2. Matrix Core Concepts: Expanding Multiple Tasks with One Click
Matrix logic is straightforward: you define several dimensions, and GitHub Actions automatically performs a Cartesian product expansion.
For example, your project needs testing on Ubuntu, Windows, and macOS, while also maintaining compatibility with Node.js 18, 20, and 22. Traditionally, you’d hand-write 9 jobs, each repeating environment definitions, installation steps, and test commands. With Matrix, it looks like this:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
These 10 lines of configuration expand into 3 x 3 = 9 parallel tasks. Each task receives different matrix.os and matrix.node values, running through all combinations.
The 800-line configuration file I mentioned earlier was reduced to about 120 lines after refactoring with Matrix—a 60%+ reduction in code. Maintenance costs dropped too—adding a new version is just adding one number to the array, no more copying and pasting job definitions.
What Matrix can do for you:
- Generate multi-platform multi-version test combinations with one click
- Automatically expand all configurations, avoiding repetitive hand-written code
- Exclude problematic combinations using exclude
- Add special configuration test cases using include
- Control concurrency to balance speed and cost
What it can’t solve:
- Poorly written tests—Matrix can’t fix that
- Bill explosions from too many combinations—that’s on you to control dimension counts
- Slow dependency installation—needs caching strategies
Honestly, Matrix itself isn’t hard to understand. The challenge is applying it to solve real engineering problems. Let’s break it down step by step, starting with basic syntax.
3. Basic Syntax: The os x version Combination Principle
Matrix combination rules are essentially Cartesian products from mathematics. Each dimension you define gets fully permuted with other dimensions.
One dimension, N values -> N tasks
Two dimensions, M x N values -> M x N tasks
Three dimensions, A x B x C values -> A x B x C tasks
Here’s a concrete example. Suppose you have a Python project that needs testing on Linux and Windows with Python 3.9, 3.10, 3.11, and 3.12, while also testing both PostgreSQL and MySQL databases:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
database: [postgresql, mysql]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup ${{ matrix.database }}
run: |
# Start the corresponding database service
if [ "${{ matrix.database }}" = "postgresql" ]; then
docker run -d -p 5432:5432 postgres
else
docker run -d -p 3306:3306 mysql
fi
shell: bash
- run: pip install -r requirements.txt
- run: pytest
This configuration generates 2 x 4 x 2 = 16 tasks. Each task runs in an independent environment without interfering with others.
Ways to access Matrix variables:
${{ matrix.os }}— Get the current task’s operating system${{ matrix.python-version }}— Get the current task’s Python version${{ matrix.database }}— Get the current task’s database type
These variables can be used in runs-on, steps, env, and other places to dynamically adjust each task’s behavior.
A common pitfall: Many people think Matrix automatically handles dependency installation. Actually, each task is an independent environment, so dependency installation gets repeated. This creates a problem—if your dependency installation takes 2 minutes, 16 tasks mean 32 minutes of waiting (assuming serial execution).
There are two solutions:
- Use caching — Cache
pipornpmdependency directories to skip repeated downloads - Reduce combination count — Exclude unnecessary test combinations via exclude
I covered caching strategies in detail in my previous article on GitHub Actions caching. Let’s focus on how to use exclude/include for precise control over test combinations.
4. exclude/include: Precise Control Over Test Combinations
Matrix defaults to fully permuting all dimensions, but in real projects, you often encounter situations where “certain combinations don’t need testing” or “certain combinations need special handling.”
4.1 exclude: Removing Invalid Combinations
I encountered this issue while maintaining a Python project: Windows + Python 3.9 combinations always failed because a dependency library had compatibility issues with Python 3.9 on Windows. But this project mainly targets Linux server deployments—Windows support is secondary—not worth fixing this specific bug.
That’s where exclude comes in handy:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
exclude:
- os: windows-latest
python-version: '3.9'
- os: macos-latest
python-version: '3.9'
This configuration excludes Python 3.9 tests on Windows and macOS. Originally 3 x 4 = 12 tasks, after excluding 2 becomes 10 tasks.
Typical exclude use cases:
- Known compatibility issues — A version doesn’t work on a specific system
- Resource constraints — Limited self-hosted runners, need to reduce combination counts
- Edge cases — Certain combinations users rarely use, not worth CI time
4.2 include: Adding Special Configurations
include does the opposite—it helps you add extra test combinations, or supplement additional variables for specific combinations.
For example, you want to enable coverage reports only in Python 3.12 tests, but not for other versions:
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
include:
- python-version: '3.12'
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt
- name: Run tests
run: |
if [ "${{ matrix.coverage }}" = "true" ]; then
pytest --cov=src --cov-report=xml
else
pytest
fi
shell: bash
Here include does two things:
- Add a new combination — Python 3.12 test
- Supplement extra variables for this combination —
coverage: true
Typical include use cases:
- Experimental version testing — Like Python 3.13 preview, only testing on one system
- Special configurations — Certain combinations need extra environment variables or parameters
- Edge case coverage — Low-frequency combinations, added individually rather than through full permutation
4.3 exclude and include Can Be Used Together
In real projects, you often need both exclude and include. For example: exclude all Python 3.9 combinations, but separately add one Ubuntu + Python 3.9 minimal test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.9', '3.10', '3.11', '3.12']
exclude:
- python-version: '3.9'
include:
- os: ubuntu-latest
python-version: '3.9'
minimal: true
This configuration executes in order: generate all combinations -> apply exclude -> apply include. Final result: 4 versions on Ubuntu, 3 versions on Windows (excluding 3.9).
5. fail-fast Strategy: Quick Failure vs Complete Debugging
Matrix tasks have a default behavior: when one task fails, other running tasks get cancelled. This behavior is called fail-fast, enabled by default.
strategy:
fail-fast: true # Default value, can be omitted
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
5.1 When to Use fail-fast: true (Default)
PR testing scenarios — A developer submits a PR, you want to quickly know if tests pass. One task fails, others likely will too (same code issues), no point wasting more time.
Cost-sensitive scenarios — GitHub Actions free tier is limited, self-hosted runner resources are limited too. Fast failure saves money.
My personal habit: use fail-fast: true for PR tests, fail-fast: false for main branch full tests.
5.2 When to Use fail-fast: false
Debugging phase — Your Matrix tasks fail frequently, but you want to know which specific combinations fail and what the different failure reasons are. With fail-fast: true, you only see the first failed task—others get cancelled.
Compatibility testing — You’re doing multi-version multi-platform compatibility testing, want to know each combination’s test results. Even if one version has issues, doesn’t affect getting information about other versions.
Complete report scenarios — You need to generate a complete test report after CI finishes, including pass/fail status for all combinations.
strategy:
fail-fast: false # Let all tasks complete
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
5.3 A Real Case
Last year I helped troubleshoot CI issues for a project. Their tests always failed on Ubuntu + Node 18, but other combinations passed. Because fail-fast was enabled by default, they could only see Ubuntu + Node 18 failing, then other tasks got cancelled. Later they wanted to know if Windows + Node 18 had issues too, so they changed to fail-fast: false, discovering Windows was fine—only Ubuntu had the problem. Eventually identified it as a file path case sensitivity compatibility issue.
So my advice: Use fail-fast: false during development debugging to see all problems clearly; use fail-fast: true during stable operation to save money and time.
6. max-parallel: Concurrency Control and Cost Optimization
Matrix tasks execute in parallel by default, with GitHub starting as many simultaneous tasks as possible. For public repositories, GitHub-hosted runner concurrency limit is 20; for private repositories, free account limit is 2.
But sometimes you need manual control over concurrency—that’s where max-parallel comes in.
6.1 When You Need to Limit Concurrency
Limited self-hosted runner resources — Your runner server only has 4 cores 8GB RAM, running 8 tasks simultaneously will overwhelm the machine.
External service rate limiting — Your tests need to call third-party APIs, they have QPS limits, too much concurrency gets you blocked.
Database connection pool limits — Your tests need database connections, connection pool only has 10 connections, too many tasks exhaust them.
strategy:
max-parallel: 4 # Maximum 4 tasks running simultaneously
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
This configuration generates 6 tasks, but at most 4 run simultaneously. One finishes, next one starts.
6.2 A Cost Calculation Example
Suppose you have a project where each CI run needs testing 3 systems x 4 Node versions = 12 tasks. Each task averages 10 minutes.
No concurrency limit (assuming enough runners):
- 12 tasks run simultaneously
- Total time about 10 minutes
- Total compute time = 12 x 10 = 120 minutes
Limit max-parallel: 4:
- 12 tasks run in 3 batches
- Total time about 30 minutes
- Total compute time = 12 x 10 = 120 minutes (unchanged)
See? max-parallel doesn’t reduce total compute time, only extends total duration. So why use it?
Because of peak concurrency costs and resource limits.
GitHub Actions bills by “minutes,” but if you use self-hosted runners, or your cloud provider bills by peak usage, concurrency control becomes important. For example, 12 tasks running simultaneously means your database needs 12 connections; running in batches, only 4 connections needed.
My practical experience:
- Public repos, GitHub-hosted runners: Don’t worry about
max-parallel, let it schedule itself - Private repos, free tier: Limit
max-parallel: 2, run slowly, don’t exceed quota - Self-hosted runners: Limit
max-parallelbased on server config, 4 cores suggest 2-4 concurrent
7. Dynamic Matrix: fromJSON Advanced Techniques
The Matrix examples earlier are all static configurations—you hardcode versions to test in YAML. But in some scenarios, you need to dynamically generate test combinations based on code changes.
For example, you have a monorepo with multiple services, each with its own test configuration. You want to: only test services affected by this commit, rather than running all services.
7.1 Two-step Workflow for Dynamic Matrix
GitHub Actions doesn’t directly support “dynamic matrix” syntax, but you can use one job to generate matrix configuration, then pass it to another job. The key is the fromJSON() function.
jobs:
# Step 1: Detect changed services, generate matrix config
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Need to get previous commit
- name: Detect changed services
id: set-matrix
run: |
# Get files changed in this commit
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
# Determine which services were modified
SERVICES="[]"
if echo "$CHANGED_FILES" | grep -q "services/auth/"; then
SERVICES=$(echo $SERVICES | jq '. + ["auth"]')
fi
if echo "$CHANGED_FILES" | grep -q "services/api/"; then
SERVICES=$(echo $SERVICES | jq '. + ["api"]')
fi
if echo "$CHANGED_FILES" | grep -q "services/web/"; then
SERVICES=$(echo $SERVICES | jq '. + ["web"]')
fi
# If no services modified, default test all services
if [ "$SERVICES" = "[]" ]; then
SERVICES='["auth", "api", "web"]'
fi
echo "matrix={\"service\":$(echo $SERVICES)}" >> $GITHUB_OUTPUT
# Step 2: Use dynamically generated matrix
test:
needs: detect
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Test ${{ matrix.service }}
run: |
cd services/${{ matrix.service }}
npm install
npm test
How this workflow works:
detectjob checks which directories were modified in this commit- Based on modified directories, dynamically generates a JSON format matrix configuration
testjob usesfromJSON()to parse this configuration and generate corresponding tasks
7.2 Typical Dynamic Matrix Use Cases
Monorepo scenarios — Only test changed services, saving CI time
On-demand deployment — Detect Dockerfile changes, only build and deploy updated images
Matrix test optimization — Decide test combinations based on file types (e.g., only run full multi-version tests when package.json changes)
Pitfalls I’ve encountered:
fromJSON()only works onstrategy.matrixvalues, not elsewhere- Generated JSON must be valid matrix format, like
{"service": ["auth", "api"]} - If generated matrix is empty, workflow errors directly—remember to add default value handling
8. Template Library: 5 Production-Ready Workflow Examples
Here are 5 workflow templates you can copy directly, covering scenarios from personal projects to enterprise applications.
8.1 Template 1: Node.js Multi-Version Testing (Basic)
Use case: Node.js libraries or applications needing compatibility with multiple Node versions
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22, 23]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test
- name: Upload coverage
if: matrix.node-version == 22
uses: codecov/codecov-action@v4
Key points:
- Use
npm ciinstead ofnpm installto ensure locked dependency versions - Only upload coverage reports on Node 22, avoiding duplicate uploads
cache: 'npm'enables npm caching to speed up dependency installation
8.2 Template 2: Python Multi-Platform Multi-Version Testing (Intermediate)
Use case: Python projects needing cross-platform cross-version testing
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.10', '3.11', '3.12']
exclude:
- os: windows-latest
python-version: '3.10' # Known compatibility issue
steps:
- uses: actions/checkout@v4
- name: Setup Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytest -v
- name: Lint check
run: |
pip install ruff
ruff check .
Key points:
- Use
excludeto remove known problematic combinations cache: 'pip'speeds up pip dependency installation- Integrate code checking tool ruff
8.3 Template 3: exclude/include Precise Control (Advanced)
Use case: Need fine-grained control over test combinations, excluding specific combinations, adding special tests
name: Advanced Matrix
on:
push:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.10', '3.11', '3.12']
exclude:
# Exclude Windows + Python 3.10 (known issue)
- os: windows-latest
python-version: '3.10'
include:
# Add experimental test: Ubuntu + Python 3.13 preview
- os: ubuntu-latest
python-version: '3.13-dev'
experimental: true
# Add coverage report for Python 3.12
- python-version: '3.12'
coverage: true
continue-on-error: ${{ matrix.experimental == true }}
steps:
- uses: actions/checkout@v4
- name: Setup Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -r requirements.txt
- name: Run tests
run: |
if [ "${{ matrix.coverage }}" = "true" ]; then
pytest --cov=src --cov-report=xml
else
pytest
fi
shell: bash
Key points:
continue-on-errorlets experimental test failures not affect overall statusincludecan simultaneously add new combinations and supplement variables- Use
shell: bashto ensure Windows and Linux commands are consistent
8.4 Template 4: Dynamic Matrix + Caching (Advanced)
Use case: Monorepo, dynamically generating test combinations based on file changes
name: Dynamic Matrix CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed packages
id: set-matrix
run: |
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
PACKAGES="[]"
for dir in packages/*/; do
pkg=$(basename $dir)
if echo "$CHANGED_FILES" | grep -q "^packages/$pkg/"; then
PACKAGES=$(echo $PACKAGES | jq ". + [\"$pkg\"]")
fi
done
# Test all packages when no changes
if [ "$PACKAGES" = "[]" ]; then
PACKAGES='["core", "utils", "cli"]'
fi
echo "matrix={\"package\":$(echo $PACKAGES)}" >> $GITHUB_OUTPUT
test:
needs: detect
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build --if-present
- name: Test ${{ matrix.package }}
run: |
cd packages/${{ matrix.package }}
npm test
Key points:
fetch-depth: 2gets previous commit for comparison- Use
jqcommand to manipulate JSON arrays - Provide default values when no changes to avoid empty matrix errors
8.5 Template 5: Self-hosted Runner + max-parallel (Enterprise)
Use case: Self-hosted runners, need strict control over concurrency and resources
name: Enterprise CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: [self-hosted, linux, x64]
strategy:
fail-fast: true
max-parallel: 4
matrix:
java-version: [11, 17, 21]
database: [postgresql, mysql]
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Java ${{ matrix.java-version }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: 'maven'
- name: Run tests with ${{ matrix.database }}
env:
DB_TYPE: ${{ matrix.database }}
DB_HOST: localhost
DB_PORT: ${{ matrix.database == 'postgresql' && 5432 || 3306 }}
run: mvn test -Dspring.profiles.active=${{ matrix.database }}
- name: Archive test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.java-version }}-${{ matrix.database }}
path: target/surefire-reports
Key points:
runs-on: [self-hosted, linux, x64]specifies self-hosted runner labelsmax-parallel: 4limits concurrency, protecting runner server- Use
servicesto start test database containers if: always()ensures test results are uploaded even if tests fail
9. Common Pitfalls and Best Practices
Using Matrix for so long, I’ve stepped in quite a few pits. Here are the most common ones.
9.1 Pitfall 1: Combination Count Explosion
The most extreme configuration I’ve seen: 4 operating systems x 5 runtime versions x 3 databases x 2 caching schemes = 120 tasks. Each CI run took 45 minutes, bills exploded.
Solutions:
- Only run full Matrix on main branch, PRs only run key combinations
- Use
excludeto remove edge cases - Evaluate necessity of each dimension—do you really need to test 4 operating systems?
# PR only tests key combinations
on:
pull_request:
branches: [main]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest] # PR only tests Ubuntu
node: [20] # PR only tests Node 20
9.2 Pitfall 2: fail-fast Hinders Debugging
Default fail-fast: true is annoying during debugging—one task fails, others get cancelled, you can’t see the complete failure report.
Solution: Manually change to fail-fast: false during debugging, change back after.
Or control with environment variables:
strategy:
fail-fast: ${{ github.event_name == 'pull_request' }}
9.3 Pitfall 3: Missing Caching
Matrix repeats the same job multiple times. If you reinstall dependencies each time, time costs are high. I once tested 12 combinations, each installation took 2 minutes—that’s 24 minutes just for installation.
Solution: Use GitHub Actions caching, or dedicated caching actions.
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm' # Key: enable npm caching
9.4 Best Practices Summary
Practice 1: Small Matrix for PRs + Full Matrix for main
jobs:
test:
strategy:
matrix:
# PR only tests key combinations
${{ github.event_name == 'pull_request' && fromJSON('{"os":["ubuntu-latest"],"node":[20]}') || fromJSON('{"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}') }}
Practice 2: exclude Removes Known Problem Combinations
When encountering compatibility issues with specific combinations, first use exclude to skip, make a TODO, fix later.
Practice 3: Combine Caching to Reduce Installation Time
Dependency installation for each job is one of the most time-consuming steps. Good caching can reduce time from minutes to seconds.
Practice 4: Add Meaningful Names to Combinations
Default task names are formatted like test (ubuntu-latest, 20). You can customize with name:
jobs:
test:
name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
This makes each task easier to identify in the GitHub Actions interface.
10. Conclusion
GitHub Actions Matrix is a powerful tool for handling multi-platform multi-version testing. The core is just a few things: define dimensions, control combinations, manage concurrency, generate dynamically.
I’ve seen too many projects still manually maintaining repetitive CI configurations—changing one command means updating dozens of places. Matrix can compress hundreds of lines of configuration into tens, maintenance costs drop immediately.
Key takeaways:
- Basic syntax:
matrix.osandmatrix.nodefor Cartesian product expansion - Precise control:
excluderemoves invalid combinations,includeadds special configurations - Strategy selection: Choose
fail-fastbased on scenario—false for debugging, true for production - Concurrency limits:
max-parallelprotects self-hosted runners, controls costs - Dynamic generation:
fromJSON()enables on-demand testing, saves CI resources
The 5 templates in this article can be copied directly into your projects, covering everything from simple Node.js multi-version testing to enterprise self-hosted runner configurations.
If you’re just starting with Matrix, I suggest starting with Template 1, get it working, then add exclude and include, finally try dynamic generation. Take it step by step—don’t dive into complexity right away.
Questions? Leave a comment. You can also check the GitHub Actions official documentation for more details. If you have Matrix usage insights, feel free to share.
Configure GitHub Actions Matrix multi-platform multi-version testing
Configure Matrix strategy from scratch for cross-platform cross-version automated testing
⏱️ Estimated time: 30 min
- 1
Step1: Define Matrix dimensions
Add strategy.matrix configuration to workflow job:
```yaml
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
```
This generates 2 x 3 = 6 parallel tasks. - 2
Step2: Use Matrix variables
Reference matrix variables in runs-on and steps:
```yaml
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
```
Each task automatically gets corresponding os and node values. - 3
Step3: Exclude specific combinations (optional)
Use exclude to remove known problematic combinations:
```yaml
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
```
Excludes Windows + Node 18 combination, generating 5 tasks. - 4
Step4: Configure failure strategy
Choose fail-fast strategy based on scenario:
- PR testing: fail-fast: true (fail fast, save money)
- Debugging phase: fail-fast: false (see all failures)
- main branch: fail-fast: false (complete report)
```yaml
strategy:
fail-fast: false
matrix:
# ...
``` - 5
Step5: Limit concurrency (optional)
Limit concurrency when self-hosted runners or resources are limited:
```yaml
strategy:
max-parallel: 4
matrix:
# ...
```
Maximum 4 tasks running simultaneously, avoiding runner overload.
FAQ
Is there a limit on Matrix combination count?
What is the default value of fail-fast?
What is the execution order of exclude and include?
Where can fromJSON() be used in dynamic matrix?
Does max-parallel reduce total compute time?
Can Matrix tasks share caches?
How to add custom names to Matrix tasks?
• name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
This displays friendly names in GitHub Actions interface like "Test (ubuntu-latest, Node 20)", making each task easy to identify.
15 min read · Published on: Apr 28, 2026 · Modified on: Apr 29, 2026
GitHub Actions Complete Guide
If you landed here from search, the fastest way to build context is to jump to the previous or next post in this same series.
Previous
GitHub Actions Secrets Management: From Leak Risks to OIDC Keyless Deployment
GitHub Actions Secrets Management Guide: Three-tier architecture strategy, 8 security rules, OIDC keyless deployment, supply chain attack protection. Learn from the tj-actions incident with workflow YAML examples and best practices
Part 6 of 7
Next
This is the latest post in the series so far.
Related Posts
GitHub Actions Matrix Build: Multi-Version Parallel Testing in Practice
GitHub Actions Matrix Build: Multi-Version Parallel Testing in Practice
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration


Comments
Sign in with GitHub to leave a comment