Test version and environment combinations locally before writing the CI matrix
Before a matrix configuration goes live in GitHub Actions or GitLab CI, prototyping it in Bash pays off: run the same combinatorics of versions, operating systems and environments locally through a loop, debug failures directly in the shell, and only then write the final YAML matrix. That saves expensive trial-and-error commits and shows which combinations actually make sense.
Table of Contents
- 1. Why simulate a matrix build idea locally in Bash first
- 2. Basic pattern: a Bash loop over versions and environments
- 3. Using Docker as an isolation layer per matrix cell
- 4. Running matrix cells in parallel instead of sequentially
- 5. Collecting results and producing a report
- 6. From local simulation to a real CI matrix configuration
- 7. Advantages and disadvantages against a native CI matrix
- 8. Caching and resource limits when testing locally
- 9. When the Bash detour pays off, and when it does not
- 10. Summary
- 11. FAQ
1. Why simulate a matrix build idea locally in Bash first
A matrix build tests the same codebase against several combinations of language version, operating system and dependency set, for example PHP 8.2, 8.3 and 8.4 each against two database versions. In a CI configuration that quickly turns into a dozen parallel jobs, but every change to that matrix costs a commit, a push and several minutes waiting for all runners to finish, just to discover a typo in an image tag.
A local Bash loop that runs the same combinatorics delivers the same information in seconds instead of minutes, because it runs directly on your own machine without a queue, without runner assignment and without network overhead. Whoever builds the matrix as a Bash prototype first catches wrong version numbers, missing environment variables and unexpected dependency conflicts before they show up in the real CI pipeline, where they are considerably more expensive to debug.
2. Basic pattern: a Bash loop over versions and environments
The core of a local matrix simulation is a nested loop over two or more Bash arrays, one per matrix dimension. Every combination of the array elements corresponds exactly to one cell of the later CI matrix, and the loop walks through them in the same cartesian order that strategy.matrix in GitHub Actions would use later.
It matters to name the dimensions from the start exactly as they will later be called in the YAML matrix, for instance PHP_VERSIONS and DB_VERSIONS, so the translation at the end is a pure copy-paste of the values instead of a new naming exercise. That reduces the risk that the local simulation ends up testing something different from the eventual real matrix.
#!/usr/bin/env bash
set -euo pipefail
readonly PHP_VERSIONS=("8.2" "8.3" "8.4")
readonly DB_VERSIONS=("mysql:8.0" "mariadb:10.11")
for php in "${PHP_VERSIONS[@]}"; do
for db in "${DB_VERSIONS[@]}"; do
echo "=== Matrix cell: PHP ${php} x ${db} ==="
# Placeholder for the actual test run of this combination
echo " -> would test PHP ${php} against ${db} here"
done
done
3. Using Docker as an isolation layer per matrix cell
A plain Bash loop alone is not enough to realistically test different PHP versions, because the local machine usually only has one version installed. Docker closes that gap: for every matrix cell a container starts with exactly the image version the later CI matrix would use, so the simulation reflects not just the logic but also the actual runtime environment.
The important part is using the same image tags that will end up in the CI configuration, for example php:8.3-cli instead of a locally diverging version. With the project directory bind-mounted, tests inside the container run exactly against the current working state, without artifacts from one matrix cell leaking into another.
#!/usr/bin/env bash
set -euo pipefail
readonly PHP_VERSIONS=("8.2" "8.3" "8.4")
for php in "${PHP_VERSIONS[@]}"; do
echo "=== PHP ${php} in Docker ==="
docker run --rm \
-v "$PWD":/app -w /app \
"php:${php}-cli" \
./run-tests.sh
done
4. Running matrix cells in parallel instead of sequentially
A sequential loop over six or more matrix cells adds up the runtime of every single test run, while a real CI matrix distributes the cells across multiple runners in parallel. To at least partly mirror that advantage locally, xargs -P reads the combinations from a list and starts up to a configured number of them as separate processes at the same time.
The number of parallel processes should be based on the local machine's core count, not the number of CI runners, since a single machine has to process every cell itself. Whoever prefers the classic & pattern followed by wait collects process IDs in an array and waits for all of them together at the end, which makes tracking individual exit codes easier.
#!/usr/bin/env bash
set -euo pipefail
readonly PHP_VERSIONS=("8.2" "8.3" "8.4")
run_cell() {
local php="$1"
docker run --rm -v "$PWD":/app -w /app "php:${php}-cli" ./run-tests.sh
}
export -f run_cell
printf '%s\n' "${PHP_VERSIONS[@]}" | xargs -P 3 -I{} bash -c 'run_cell "$@"' _ {}
5. Collecting results and producing a report
Test runs started in parallel scribble their output over each other on the same terminal unless every matrix cell is redirected to its own log file. An associative array that stores the exit code of each cell under a descriptive key like php83-mariadb makes it clear at the end, in a single loop, which combinations failed, without opening every scattered log file by hand.
A simple closing report that summarizes pass and fail lines mirrors, in reduced form, the matrix overview GitHub Actions or GitLab CI shows in the web interface. For quick local debugging that is entirely sufficient, because the goal here is not a polished dashboard but seeing within seconds which version combination needs attention next.
#!/usr/bin/env bash
set -euo pipefail
declare -A RESULTS=()
for php in 8.2 8.3 8.4; do
log="/tmp/matrix-${php}.log"
if docker run --rm -v "$PWD":/app -w /app "php:${php}-cli" ./run-tests.sh > "$log" 2>&1; then
RESULTS["php${php}"]="PASS"
else
RESULTS["php${php}"]="FAIL (see $log)"
fi
done
echo "=== Matrix report ==="
for key in "${!RESULTS[@]}"; do
echo " ${key}: ${RESULTS[$key]}"
done
6. From local simulation to a real CI matrix configuration
Once the Bash simulation runs reliably, translating it into a real CI matrix is usually a formality: the values from PHP_VERSIONS and DB_VERSIONS move unchanged into strategy.matrix under GitHub Actions, or into the parallel: matrix: section under GitLab CI. Because every combination has already run locally, the usual series of small fix-up commits that only exist to find typos in version numbers disappears.
Whoever explicitly excludes certain combinations in the Bash loop, for instance because PHP 8.2 is not compatible with a certain database version, can carry those exclusions directly over as exclude entries in the YAML matrix. The local simulation then becomes not just a test run but also documentation of which cells of the matrix even make sense.
7. Advantages and disadvantages against a native CI matrix
The biggest advantage of the local Bash simulation is feedback speed: no waiting for free runners, no CI minute cost, and the ability to jump into a failed matrix cell with a debugger or interactive shell, which in a running CI pipeline is only possible with extra tooling like SSH debug sessions.
The downside is that a local machine never reaches the parallelism of a CI fleet with dozens of runners, and the nice matrix overview with green and red tiles, per-cell artifact uploads and automatic pull request comments is entirely missing. The Bash simulation is a development tool for the phase a matrix is being built in, not a replacement for production CI execution.
8. Caching and resource limits when testing locally
Every extra matrix dimension multiplies the number of Docker images needed, and without cleanup the local disk quickly fills up with dozens of image variants no longer needed after prototyping. Running docker image prune regularly after a completed simulation keeps disk usage in check without deleting images still actively in use.
Because a local machine typically has far fewer CPU cores and less memory than an entire CI runner fleet, the local matrix does not necessarily need to cover every single combination of the later real matrix. A representative subset, for instance the oldest and newest supported version per dimension, covers most real-world failure sources while keeping local runtime manageable.
9. When the Bash detour pays off, and when it does not
The Bash simulation detour pays off especially when building a new matrix, debugging a version-specific error that only shows up in one particular cell, or when CI minutes are expensive and every unnecessary pipeline run should be avoided. It is also useful when reproducing a reported bug that only occurs under an old language version, where the local loop is often faster than a CI retrigger.
For an already stable matrix that has not changed in months, the extra maintenance burden of a parallel Bash version does not pay off, since hardly any iteration happens anymore and the native CI matrix fully plays out its strengths in parallelism and reporting. The Bash simulation is a tool for the development phase, not for the steady-state operation of an established pipeline.
| Criterion | Bash loop locally | Native CI matrix | Recommendation |
|---|---|---|---|
| Feedback speed | Seconds, no waiting for runners | Minutes, depends on runner queue | Bash for fast iteration |
| Parallelism | Limited by local CPU cores | Dozens of runners at once possible | CI for large matrices |
| Cost | No CI minutes consumed | CI minutes per cell | Bash during prototyping |
| Debugging | Direct shell access to a failed cell | Only through logs or SSH debug sessions | Bash for root-cause analysis |
| Reporting | Simple text report | Graphical matrix overview, PR comments | CI for production operation |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Simulating Matrix Builds with Bash: The Essentials at a Glance
Core idea
Nested Bash loops over arrays mirror the same combinatorics that later ends up in strategy.matrix or parallel: matrix.
Isolation
Docker with the same image tags as the later CI matrix makes the local simulation realistic, not just logically correct.
Parallelism
xargs -P or the & with wait pattern shortens total runtime, bounded by the local CPU core count.
Limits
No runner parallelism, no graphical reporting. The Bash simulation is a development tool, not a CI replacement.