my blog

$ 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:

  1. ubuntu-latest + Node 18.x
  2. ubuntu-latest + Node 20.x
  3. ubuntu-latest + Node 21.x
  4. windows-latest + Node 18.x
  5. windows-latest + Node 20.x
  6. 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:

  1. Loads variables and secrets specific to that environment
  2. Shows the environment in your deployments dashboard
  3. Can require manual approval before running (if you configure it)
  4. 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:

  1. Deploy to staging – Test environment deployment
  2. Run E2E tests – Validate staging deployment works
  3. 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

  1. Matrix is multiplicative: 3 node versions × 2 OS = 6 jobs. Watch out for explosion! 5 × 4 × 3 = 60 jobs.
  2. Cache keys must include matrix values: Otherwise jobs overwrite each other’s caches:
  3. Use custom job names: Without them, you can’t tell jobs apart in the UI.
  4. fail-fast is a trade-off:
  5. 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.
  6. Use continue-on-error for experimental: Let cutting-edge versions fail without blocking your pipeline:

About Environments

  1. Environments are more than variables: They give you deployment history, approval workflows, and branch protection.
  2. Required reviewers for prod: I always set this. It prevents accidental production deployments.
  3. Use environment-specific secrets: API keys and credentials should be different for staging vs prod.
  4. Deployment visualization: GitHub shows a nice graph of what deployed where and when. This is useful for rollbacks.
  5. Parallel deployments share approval: If 3 jobs use environment: prod, they can all run after ONE approval, not three separate approvals.
  6. 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:

  1. Test on multiple OS (matrix)
  2. Deploy to staging (environment)
  3. Run smoke tests
  4. Wait for approval (production environment protection)
  5. Deploy to multiple regions in parallel (matrix + environment)

$ ls -t root-access/ | head -3

Hello world! uncategorized 1 min Building a Job Board API, Part 1: The Foundation job-board-api · 1/1 16 min Part 13 – Kubernetes Autoscaling in Action: A FastAPI Microservice That Scales Itself home-lab · 13/13 6 min

Leave a comment

Your email address will not be published. Required fields are marked *