$ cat github-actions/part-04.md
Matrix Strategy & Environment Deployments in GitHub Actions

After covering outputs, caching, and artifacts, now I want to share how to test across multiple configurations and deploy to different environments. This is where GitHub Actions really shines for real-world CI/CD.
Matrix Strategy: Test Everything Without Code Duplication
Matrix lets you run the same job with different configurations. Instead of writing separate jobs for Node 18, 20, and 21, you write one job and let GitHub create multiple instances.
Here’s my matrix workflow:
name: 15 - Working with Matrices
on: workflow_dispatch
jobs:
backwards-compatibility:
name: ${{ matrix.os }}-${{ matrix.node-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
node-version: [18.x, 20.x, 21.x]
os:
- ubuntu-latest
- windows-latest
include:
- os: ubuntu-latest
node-version: 16.x
- os: ubuntu-latest
node-version: 21.x
tag: experimental
steps:
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Perform some tests
run: |
echo "Running tests on OS ${{ matrix.os }} and NodeJS ${{ matrix.node-version }}"
- name: Upload test results
run: echo "Uploading test results"
Understanding the Matrix
This matrix creates 6 jobs automatically:
- ubuntu-latest + Node 18.x
- ubuntu-latest + Node 20.x
- ubuntu-latest + Node 21.x
- windows-latest + Node 18.x
- windows-latest + Node 20.x
- windows-latest + Node 21.x
Plus 2 more from include: 7. ubuntu-latest + Node 16.x 8. ubuntu-latest + Node 21.x (with tag: experimental)
Total: 8 jobs from one definition!
The Strategy Section
strategy:
fail-fast: true
matrix:
node-version: [18.x, 20.x, 21.x]
os:
- ubuntu-latest
- windows-latest
fail-fast: true means if one combination fails, GitHub cancels all other running jobs. This saves time and runner minutes.
If you set fail-fast: false, all combinations run even if some fail. I use this when I want to see which specific combinations are broken.
Using Matrix Values
Access matrix values with ${{ matrix.VARIABLE }}:
runs-on: ${{ matrix.os }}
steps:
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
Each job gets different values based on the combination GitHub creates.
Custom Job Names
name: ${{ matrix.os }}-${{ matrix.node-version }}
This creates clear job names like:
- ubuntu-latest-18.x
- windows-latest-20.x
- ubuntu-latest-21.x
Without custom names, all jobs would be called “backwards-compatibility” and you couldn’t tell them apart.
Advanced Matrix: Include and Exclude
The include and exclude keywords give you fine control.
Include: Adding Extra Combinations
include:
- os: ubuntu-latest
node-version: 16.x
- os: ubuntu-latest
node-version: 21.x
tag: experimental
This adds combinations that aren’t in the base matrix. Notice the second one adds an extra property tag: experimental that only exists for that specific combination.
Exclude: Removing Combinations
Here’s a more complex example from my workflow:
jobs:
include-example:
name: ${{ matrix.color }}-${{ matrix.shape }}-${{ matrix.size }}-${{ matrix.opacity }}
runs-on: ubuntu-latest
strategy:
matrix:
color: [red, green]
shape: [circle, square]
size: [small, large]
include:
- color: red
opacity: 75
- shape: circle
opacity: 100
- color: red
shape: triangle
- opacity: 50
- color: green
shape: circle
size: medium
exclude:
- color: green
shape: circle
steps:
- name: Dummy step
run: echo "${{ matrix.color }}-${{ matrix.shape }}-${{ matrix.size }}-${{ matrix.opacity }}"
Base matrix creates: 2 colors × 2 shapes × 2 sizes = 8 combinations
Exclude removes: All combinations where color is green AND shape is circle
This is useful when certain combinations don’t make sense, like testing Windows with Linux-specific tools.
Include Rules I Learned
The include keyword works in different ways:
1. Add property to existing combinations:
- color: red
opacity: 75
This adds opacity: 75 to ALL combinations where color: red.
2. Add new combination:
- color: red
shape: triangle
If triangle doesn’t exist in base matrix, this creates a new combination: red-triangle-(all sizes).
3. Add property to specific combination:
- color: green
shape: circle
size: medium
This adds size: medium as a fourth option, but only for green circles.
4. Add standalone property:
- opacity: 50
This adds opacity: 50 to ALL combinations.
Real-World Matrix Example
Here’s how I use matrix for testing a React app across different environments:
name: Cross-Platform Testing
on:
pull_request:
push:
branches: [main]
jobs:
test:
name: Test on ${{ matrix.os }} with Node ${{ matrix.node-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x]
include:
- os: ubuntu-latest
node-version: 21.x
experimental: true
continue-on-error: ${{ matrix.experimental || false }}
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache dependencies
uses: actions/cache@v3
with:
path: node_modules
key: ${{ matrix.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
What’s happening:
- Tests on 3 operating systems × 2 Node versions = 6 jobs
- Plus 1 experimental job with Node 21.x on Ubuntu
- continue-on-error: ${{ matrix.experimental || false }} allows the experimental job to fail without failing the whole workflow
- Cache keys include both OS and Node version for proper isolation
- fail-fast: false lets me see which specific combinations fail
Environment Deployments: Managing Different Stages
Environments let you define deployment targets with their own variables, secrets, and protection rules.
Here’s my environment deployment workflow:
name: 16 - Working with Environments
on: workflow_dispatch
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: stage
env:
my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}
steps:
- name: Echo vars
run: |
echo "Deploying to staging"
e2e-tests:
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- name: E2E tests
run: echo "Running E2e"
deploy-prod-frontend:
runs-on: ubuntu-latest
needs: e2e-tests
environment: prod
env:
my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}
steps:
- name: Echo vars
run: |
echo "Deploying prod frontend"
deploy-prod-backend1:
runs-on: ubuntu-latest
needs: e2e-tests
environment: prod
env:
my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}
steps:
- name: Echo vars
run: |
echo "Deploying prod backend 1"
deploy-prod-backend2:
runs-on: ubuntu-latest
needs: e2e-tests
environment: prod
env:
my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}
steps:
- name: Echo vars
run: |
echo "Deploying prod backend 2"
Understanding Environments
The key line is:
environment: prod
When you set this, GitHub:
- Loads variables and secrets specific to that environment
- Shows the environment in your deployments dashboard
- Can require manual approval before running (if you configure it)
- Can restrict which branches can deploy to it
Environment-Specific Variables
env:
my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}
vars.MY_ENV_VALUE can have different values:
- In stage environment: might be “https://staging.example.com”
- In prod environment: might be “https://example.com”
You set these in GitHub Settings → Environments → Variables.
The Deployment Flow
My workflow shows a typical deployment pipeline:
- Deploy to staging – Test environment deployment
- Run E2E tests – Validate staging deployment works
- Deploy to prod – Three parallel production deployments: Frontend Backend 1 Backend 2
All prod deployments wait for E2E tests to pass (needs: e2e-tests).
Environment Protection Rules
In GitHub Settings, you can configure:
Required reviewers: Someone must approve before the job runs. This is huge for production deployments.
Wait timer: Add a delay before deployment starts (like “wait 5 minutes after staging”).
Deployment branches: Only allow deployments from main or release/* branches.
When a job hits an environment with protection rules, GitHub pauses the workflow and shows “Waiting for approval” in the UI.
Combining Matrix with Environments
Here’s how I combine both concepts for multi-region deployments:
name: Multi-Region Deployment
on:
workflow_dispatch:
inputs:
environment:
type: choice
options:
- staging
- production
required: true
jobs:
deploy:
name: Deploy to ${{ matrix.region }}
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
strategy:
matrix:
region: [us-east-1, eu-west-1, ap-southeast-1]
steps:
- uses: actions/checkout@v4
- name: Configure AWS
run: |
echo "Configuring AWS for region ${{ matrix.region }}"
echo "Environment: ${{ inputs.environment }}"
- name: Deploy application
env:
API_URL: ${{ vars.API_URL }}
DB_HOST: ${{ vars.DB_HOST }}
REGION: ${{ matrix.region }}
run: |
echo "Deploying to ${{ matrix.region }}"
echo "API URL: $API_URL"
echo "DB Host: $DB_HOST"
This creates 3 parallel deployment jobs (one per region) that all use the same environment variables but deploy to different AWS regions.
What I Learned
About Matrix Strategy
- Matrix is multiplicative: 3 node versions × 2 OS = 6 jobs. Watch out for explosion! 5 × 4 × 3 = 60 jobs.
- Cache keys must include matrix values: Otherwise jobs overwrite each other’s caches:
- Use custom job names: Without them, you can’t tell jobs apart in the UI.
- fail-fast is a trade-off:
- Include vs Exclude order matters: GitHub processes exclude first, then include. So you can exclude a combination and then add it back with extra properties.
- Use continue-on-error for experimental: Let cutting-edge versions fail without blocking your pipeline:
About Environments
- Environments are more than variables: They give you deployment history, approval workflows, and branch protection.
- Required reviewers for prod: I always set this. It prevents accidental production deployments.
- Use environment-specific secrets: API keys and credentials should be different for staging vs prod.
- Deployment visualization: GitHub shows a nice graph of what deployed where and when. This is useful for rollbacks.
- Parallel deployments share approval: If 3 jobs use environment: prod, they can all run after ONE approval, not three separate approvals.
- Environment URL: You can set a URL for each environment in GitHub settings. It appears in pull requests as “View deployment” link.
Real-World Pattern: Staged Rollout
Here’s how I combine everything for a production-grade deployment:
name: Production Deployment
on:
push:
branches: [main]
jobs:
test:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
- run: npm ci
- run: npm test
deploy-staging:
needs: test
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: echo "Deploying to staging"
smoke-tests:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- name: Run smoke tests
run: echo "Running smoke tests against staging"
deploy-production:
needs: smoke-tests
runs-on: ubuntu-latest
environment: production
strategy:
matrix:
region: [us-east-1, eu-west-1]
steps:
- uses: actions/checkout@v4
- name: Deploy to ${{ matrix.region }}
env:
REGION: ${{ matrix.region }}
API_KEY: ${{ secrets.API_KEY }}
DB_URL: ${{ vars.DB_URL }}
run: |
echo "Deploying to production in $REGION"
The flow:
- Test on multiple OS (matrix)
- Deploy to staging (environment)
- Run smoke tests
- Wait for approval (production environment protection)
- Deploy to multiple regions in parallel (matrix + environment)