{"id":70,"date":"2025-12-22T12:00:00","date_gmt":"2025-12-22T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=70"},"modified":"2026-10-30T06:47:18","modified_gmt":"2026-10-30T06:47:18","slug":"matrix-strategy-environment-deployments-in-github-actions","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=70","title":{"rendered":"Matrix Strategy &#038; Environment Deployments in GitHub Actions"},"content":{"rendered":"<p class=\"wp-block-paragraph\">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.<\/p>\n<h3 class=\"wp-block-heading\">Matrix Strategy: Test Everything Without Code Duplication<\/h3>\n<p class=\"wp-block-paragraph\">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.<\/p>\n<p class=\"wp-block-paragraph\">Here&#8217;s my matrix workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: 15 - Working with Matrices\n\non: workflow_dispatch\n\njobs:\n  backwards-compatibility:\n    name: ${{ matrix.os }}-${{ matrix.node-version }}\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: true\n      matrix:\n        node-version: [18.x, 20.x, 21.x]\n        os:\n          - ubuntu-latest\n          - windows-latest\n        include:\n          - os: ubuntu-latest\n            node-version: 16.x\n          - os: ubuntu-latest\n            node-version: 21.x\n            tag: experimental\n    steps:\n      - name: Setup node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n      - name: Perform some tests\n        run: |\n          echo \"Running tests on OS ${{ matrix.os }} and NodeJS ${{ matrix.node-version }}\"\n      - name: Upload test results\n        run: echo \"Uploading test results\"<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Understanding the Matrix<\/h3>\n<p class=\"wp-block-paragraph\">This matrix creates 6 jobs automatically:<\/p>\n<ol class=\"wp-block-list\"><li>ubuntu-latest + Node 18.x<\/li><li>ubuntu-latest + Node 20.x<\/li><li>ubuntu-latest + Node 21.x<\/li><li>windows-latest + Node 18.x<\/li><li>windows-latest + Node 20.x<\/li><li>windows-latest + Node 21.x<\/li><\/ol>\n<p class=\"wp-block-paragraph\">Plus 2 more from include: 7. ubuntu-latest + Node 16.x 8. ubuntu-latest + Node 21.x (with tag: experimental)<\/p>\n<p class=\"wp-block-paragraph\">Total: 8 jobs from one definition!<\/p>\n<h3 class=\"wp-block-heading\">The Strategy Section<\/h3>\n<pre class=\"wp-block-code\"><code>strategy:\n  fail-fast: true\n  matrix:\n    node-version: [18.x, 20.x, 21.x]\n    os:\n      - ubuntu-latest\n      - windows-latest<\/code><\/pre>\n<p class=\"wp-block-paragraph\">fail-fast: true means if one combination fails, GitHub cancels all other running jobs. This saves time and runner minutes.<\/p>\n<p class=\"wp-block-paragraph\">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.<\/p>\n<h3 class=\"wp-block-heading\">Using Matrix Values<\/h3>\n<p class=\"wp-block-paragraph\">Access matrix values with ${{ matrix.VARIABLE }}:<\/p>\n<pre class=\"wp-block-code\"><code>runs-on: ${{ matrix.os }}\n\nsteps:\n  - name: Setup node\n    uses: actions\/setup-node@v4\n    with:\n      node-version: ${{ matrix.node-version }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Each job gets different values based on the combination GitHub creates.<\/p>\n<h3 class=\"wp-block-heading\">Custom Job Names<\/h3>\n<pre class=\"wp-block-code\"><code>name: ${{ matrix.os }}-${{ matrix.node-version }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This creates clear job names like:<\/p>\n<ul class=\"wp-block-list\"><li>ubuntu-latest-18.x<\/li><li>windows-latest-20.x<\/li><li>ubuntu-latest-21.x<\/li><\/ul>\n<p class=\"wp-block-paragraph\">Without custom names, all jobs would be called &#8220;backwards-compatibility&#8221; and you couldn&#8217;t tell them apart.<\/p>\n<h3 class=\"wp-block-heading\">Advanced Matrix: Include and Exclude<\/h3>\n<p class=\"wp-block-paragraph\">The include and exclude keywords give you fine control.<\/p>\n<h3 class=\"wp-block-heading\">Include: Adding Extra Combinations<\/h3>\n<pre class=\"wp-block-code\"><code>include:\n  - os: ubuntu-latest\n    node-version: 16.x\n  - os: ubuntu-latest\n    node-version: 21.x\n    tag: experimental<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This adds combinations that aren&#8217;t in the base matrix. Notice the second one adds an extra property tag: experimental that only exists for that specific combination.<\/p>\n<h3 class=\"wp-block-heading\">Exclude: Removing Combinations<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s a more complex example from my workflow:<\/p>\n<pre class=\"wp-block-code\"><code>jobs:\n  include-example:\n    name: ${{ matrix.color }}-${{ matrix.shape }}-${{ matrix.size }}-${{ matrix.opacity }}\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        color: [red, green]\n        shape: [circle, square]\n        size: [small, large]\n        include:\n          - color: red\n            opacity: 75\n          - shape: circle\n            opacity: 100\n          - color: red\n            shape: triangle\n          - opacity: 50\n          - color: green\n            shape: circle\n            size: medium\n        exclude:\n          - color: green\n            shape: circle\n    steps:\n      - name: Dummy step\n        run: echo \"${{ matrix.color }}-${{ matrix.shape }}-${{ matrix.size }}-${{ matrix.opacity }}\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">Base matrix creates: 2 colors \u00d7 2 shapes \u00d7 2 sizes = 8 combinations<\/p>\n<p class=\"wp-block-paragraph\">Exclude removes: All combinations where color is green AND shape is circle<\/p>\n<p class=\"wp-block-paragraph\">This is useful when certain combinations don&#8217;t make sense, like testing Windows with Linux-specific tools.<\/p>\n<h3 class=\"wp-block-heading\">Include Rules I Learned<\/h3>\n<p class=\"wp-block-paragraph\">The include keyword works in different ways:<\/p>\n<p class=\"wp-block-paragraph\">1. Add property to existing combinations:<\/p>\n<pre class=\"wp-block-code\"><code>- color: red\n  opacity: 75<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This adds opacity: 75 to ALL combinations where color: red.<\/p>\n<p class=\"wp-block-paragraph\">2. Add new combination:<\/p>\n<pre class=\"wp-block-code\"><code>- color: red\n  shape: triangle<\/code><\/pre>\n<p class=\"wp-block-paragraph\">If triangle doesn&#8217;t exist in base matrix, this creates a new combination: red-triangle-(all sizes).<\/p>\n<p class=\"wp-block-paragraph\">3. Add property to specific combination:<\/p>\n<pre class=\"wp-block-code\"><code>- color: green\n  shape: circle\n  size: medium<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This adds size: medium as a fourth option, but only for green circles.<\/p>\n<p class=\"wp-block-paragraph\">4. Add standalone property:<\/p>\n<pre class=\"wp-block-code\"><code>- opacity: 50<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This adds opacity: 50 to ALL combinations.<\/p>\n<h3 class=\"wp-block-heading\">Real-World Matrix Example<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I use matrix for testing a React app across different environments:<\/p>\n<pre class=\"wp-block-code\"><code>name: Cross-Platform Testing\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    name: Test on ${{ matrix.os }} with Node ${{ matrix.node-version }}\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n        node-version: [18.x, 20.x]\n        include:\n          - os: ubuntu-latest\n            node-version: 21.x\n            experimental: true\n    continue-on-error: ${{ matrix.experimental || false }}\n    steps:\n      - uses: actions\/checkout@v4\n      - name: Setup Node\n        uses: actions\/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n      - name: Cache dependencies\n        uses: actions\/cache@v3\n        with:\n          path: node_modules\n          key: ${{ matrix.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**\/package-lock.json') }}\n      - name: Install dependencies\n        run: npm ci\n      - name: Run tests\n        run: npm test\n      - name: Build\n        run: npm run build<\/code><\/pre>\n<p class=\"wp-block-paragraph\">What&#8217;s happening:<\/p>\n<ul class=\"wp-block-list\"><li>Tests on 3 operating systems \u00d7 2 Node versions = 6 jobs<\/li><li>Plus 1 experimental job with Node 21.x on Ubuntu<\/li><li>continue-on-error: ${{ matrix.experimental || false }} allows the experimental job to fail without failing the whole workflow<\/li><li>Cache keys include both OS and Node version for proper isolation<\/li><li>fail-fast: false lets me see which specific combinations fail<\/li><\/ul>\n<h3 class=\"wp-block-heading\">Environment Deployments: Managing Different Stages<\/h3>\n<p class=\"wp-block-paragraph\">Environments let you define deployment targets with their own variables, secrets, and protection rules.<\/p>\n<p class=\"wp-block-paragraph\">Here&#8217;s my environment deployment workflow:<\/p>\n<pre class=\"wp-block-code\"><code>name: 16 - Working with Environments\n\non: workflow_dispatch\n\njobs:\n  deploy-staging:\n    runs-on: ubuntu-latest\n    environment: stage\n    env:\n      my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}\n    steps:\n      - name: Echo vars\n        run: |\n          echo \"Deploying to staging\"\n  e2e-tests:\n    runs-on: ubuntu-latest\n    needs: deploy-staging\n    steps:\n      - name: E2E tests\n        run: echo \"Running E2e\"\n  deploy-prod-frontend:\n    runs-on: ubuntu-latest\n    needs: e2e-tests\n    environment: prod\n    env:\n      my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}\n    steps:\n      - name: Echo vars\n        run: |\n          echo \"Deploying prod frontend\"\n  deploy-prod-backend1:\n    runs-on: ubuntu-latest\n    needs: e2e-tests\n    environment: prod\n    env:\n      my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}\n    steps:\n      - name: Echo vars\n        run: |\n          echo \"Deploying prod backend 1\"\n  deploy-prod-backend2:\n    runs-on: ubuntu-latest\n    needs: e2e-tests\n    environment: prod\n    env:\n      my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}\n    steps:\n      - name: Echo vars\n        run: |\n          echo \"Deploying prod backend 2\"<\/code><\/pre>\n<h3 class=\"wp-block-heading\">Understanding Environments<\/h3>\n<p class=\"wp-block-paragraph\">The key line is:<\/p>\n<pre class=\"wp-block-code\"><code>environment: prod<\/code><\/pre>\n<p class=\"wp-block-paragraph\">When you set this, GitHub:<\/p>\n<ol class=\"wp-block-list\"><li>Loads variables and secrets specific to that environment<\/li><li>Shows the environment in your deployments dashboard<\/li><li>Can require manual approval before running (if you configure it)<\/li><li>Can restrict which branches can deploy to it<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Environment-Specific Variables<\/h3>\n<pre class=\"wp-block-code\"><code>env:\n  my-env-value: ${{ vars.MY_ENV_VALUE || 'default value' }}<\/code><\/pre>\n<p class=\"wp-block-paragraph\">vars.MY_ENV_VALUE can have different values:<\/p>\n<ul class=\"wp-block-list\"><li>In stage environment: might be &#8220;https:\/\/staging.example.com&#8221;<\/li><li>In prod environment: might be &#8220;https:\/\/example.com&#8221;<\/li><\/ul>\n<p class=\"wp-block-paragraph\">You set these in GitHub Settings \u2192 Environments \u2192 Variables.<\/p>\n<h3 class=\"wp-block-heading\">The Deployment Flow<\/h3>\n<p class=\"wp-block-paragraph\">My workflow shows a typical deployment pipeline:<\/p>\n<ol class=\"wp-block-list\"><li>Deploy to staging &#8211; Test environment deployment<\/li><li>Run E2E tests &#8211; Validate staging deployment works<\/li><li>Deploy to prod &#8211; Three parallel production deployments: Frontend Backend 1 Backend 2<\/li><\/ol>\n<p class=\"wp-block-paragraph\">All prod deployments wait for E2E tests to pass (needs: e2e-tests).<\/p>\n<h3 class=\"wp-block-heading\">Environment Protection Rules<\/h3>\n<p class=\"wp-block-paragraph\">In GitHub Settings, you can configure:<\/p>\n<p class=\"wp-block-paragraph\"><strong>Required reviewers:<\/strong> Someone must approve before the job runs. This is huge for production deployments.<\/p>\n<p class=\"wp-block-paragraph\"><strong>Wait timer:<\/strong> Add a delay before deployment starts (like &#8220;wait 5 minutes after staging&#8221;).<\/p>\n<p class=\"wp-block-paragraph\"><strong>Deployment branches:<\/strong> Only allow deployments from main or release\/* branches.<\/p>\n<p class=\"wp-block-paragraph\">When a job hits an environment with protection rules, GitHub pauses the workflow and shows &#8220;Waiting for approval&#8221; in the UI.<\/p>\n<h3 class=\"wp-block-heading\">Combining Matrix with Environments<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I combine both concepts for multi-region deployments:<\/p>\n<pre class=\"wp-block-code\"><code>name: Multi-Region Deployment\n\non:\n  workflow_dispatch:\n    inputs:\n      environment:\n        type: choice\n        options:\n          - staging\n          - production\n        required: true\n\njobs:\n  deploy:\n    name: Deploy to ${{ matrix.region }}\n    runs-on: ubuntu-latest\n    environment: ${{ inputs.environment }}\n    strategy:\n      matrix:\n        region: [us-east-1, eu-west-1, ap-southeast-1]\n    steps:\n      - uses: actions\/checkout@v4\n      - name: Configure AWS\n        run: |\n          echo \"Configuring AWS for region ${{ matrix.region }}\"\n          echo \"Environment: ${{ inputs.environment }}\"\n      - name: Deploy application\n        env:\n          API_URL: ${{ vars.API_URL }}\n          DB_HOST: ${{ vars.DB_HOST }}\n          REGION: ${{ matrix.region }}\n        run: |\n          echo \"Deploying to ${{ matrix.region }}\"\n          echo \"API URL: $API_URL\"\n          echo \"DB Host: $DB_HOST\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">This creates 3 parallel deployment jobs (one per region) that all use the same environment variables but deploy to different AWS regions.<\/p>\n<h3 class=\"wp-block-heading\">What I Learned<\/h3>\n<h3 class=\"wp-block-heading\">About Matrix Strategy<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Matrix is multiplicative:<\/strong> 3 node versions \u00d7 2 OS = 6 jobs. Watch out for explosion! 5 \u00d7 4 \u00d7 3 = 60 jobs.<\/li><li><strong>Cache keys must include matrix values:<\/strong> Otherwise jobs overwrite each other&#8217;s caches:<\/li><li><strong>Use custom job names:<\/strong> Without them, you can&#8217;t tell jobs apart in the UI.<\/li><li><strong>fail-fast is a trade-off:<\/strong><\/li><li><strong>Include vs Exclude order matters:<\/strong> GitHub processes exclude first, then include. So you can exclude a combination and then add it back with extra properties.<\/li><li><strong>Use continue-on-error for experimental:<\/strong> Let cutting-edge versions fail without blocking your pipeline:<\/li><\/ol>\n<h3 class=\"wp-block-heading\">About Environments<\/h3>\n<ol class=\"wp-block-list\"><li><strong>Environments are more than variables:<\/strong> They give you deployment history, approval workflows, and branch protection.<\/li><li><strong>Required reviewers for prod:<\/strong> I always set this. It prevents accidental production deployments.<\/li><li><strong>Use environment-specific secrets:<\/strong> API keys and credentials should be different for staging vs prod.<\/li><li><strong>Deployment visualization:<\/strong> GitHub shows a nice graph of what deployed where and when. This is useful for rollbacks.<\/li><li><strong>Parallel deployments share approval:<\/strong> If 3 jobs use environment: prod, they can all run after ONE approval, not three separate approvals.<\/li><li><strong>Environment URL:<\/strong> You can set a URL for each environment in GitHub settings. It appears in pull requests as &#8220;View deployment&#8221; link.<\/li><\/ol>\n<h3 class=\"wp-block-heading\">Real-World Pattern: Staged Rollout<\/h3>\n<p class=\"wp-block-paragraph\">Here&#8217;s how I combine everything for a production-grade deployment:<\/p>\n<pre class=\"wp-block-code\"><code>name: Production Deployment\n\non:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    name: Test on ${{ matrix.os }}\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: actions\/setup-node@v4\n        with:\n          node-version: '20.x'\n      - run: npm ci\n      - run: npm test\n  deploy-staging:\n    needs: test\n    runs-on: ubuntu-latest\n    environment: staging\n    steps:\n      - uses: actions\/checkout@v4\n      - name: Deploy to staging\n        run: echo \"Deploying to staging\"\n  smoke-tests:\n    needs: deploy-staging\n    runs-on: ubuntu-latest\n    steps:\n      - name: Run smoke tests\n        run: echo \"Running smoke tests against staging\"\n  deploy-production:\n    needs: smoke-tests\n    runs-on: ubuntu-latest\n    environment: production\n    strategy:\n      matrix:\n        region: [us-east-1, eu-west-1]\n    steps:\n      - uses: actions\/checkout@v4\n      - name: Deploy to ${{ matrix.region }}\n        env:\n          REGION: ${{ matrix.region }}\n          API_KEY: ${{ secrets.API_KEY }}\n          DB_URL: ${{ vars.DB_URL }}\n        run: |\n          echo \"Deploying to production in $REGION\"<\/code><\/pre>\n<p class=\"wp-block-paragraph\">The flow:<\/p>\n<ol class=\"wp-block-list\"><li>Test on multiple OS (matrix)<\/li><li>Deploy to staging (environment)<\/li><li>Run smoke tests<\/li><li>Wait for approval (production environment protection)<\/li><li>Deploy to multiple regions in parallel (matrix + environment)<\/li><\/ol>","protected":false},"excerpt":{"rendered":"<p>After covering outputs, caching, and artifacts, now I want to share how to test across multiple configurations and deploy to different environments.&hellip;<\/p>\n","protected":false},"author":2,"featured_media":72,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"4","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/matrix-strategy-environment-deployments-github-actions-reza-chegini-mdedc\/","footnotes":""},"categories":[27,10],"tags":[30,13,8,29,34],"series":[28],"class_list":["post-70","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ci-cd","category-devops","tag-ci-cd","tag-deployments","tag-devops","tag-github-actions","tag-matrix-builds","series-github-actions"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/matrix-strategy-environment-deployments-github-actions-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/70","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=70"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/70\/revisions"}],"predecessor-version":[{"id":71,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/70\/revisions\/71"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/72"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=70"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=70"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=70"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=70"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}