DevOps

GitHub Actions: CI/CD from Basics to Matrix Builds

Master GitHub Actions for CI/CD. Learn to build pipelines from basic pushes to advanced matrix builds, ensuring robust, efficient software delivery.

Khader Vali June 20, 2026 17 min read

GitHub Actions: CI/CD from Basics to Matrix Builds

As a senior software engineer, I’ve seen firsthand how critical robust CI/CD pipelines are to a team’s productivity and the reliability of their software. In today’s fast-paced development landscape, manual processes are not just slow; they’re a liability. This is where Continuous Integration (CI) and Continuous Delivery/Deployment (CD) come in, and GitHub Actions has emerged as a powerful, integrated solution for implementing them.

In this comprehensive guide, we’ll embark on a journey through GitHub Actions, starting from its fundamental concepts and building up to advanced techniques like matrix builds. Whether you’re new to CI/CD or looking to optimize your existing workflows, you’ll find actionable insights and practical examples here. My goal is to equip you with the knowledge to design, build, and maintain efficient, scalable, and secure CI/CD pipelines right within your GitHub repositories.

Understanding CI/CD and the Role of GitHub Actions

What is CI/CD?

Continuous Integration (CI) is a development practice where developers frequently integrate code into a shared repository, usually several times a day. Each integration is then verified by an automated build and automated tests. The primary goals of CI are to detect integration errors early and to provide rapid feedback to developers.

Continuous Delivery (CD) is an extension of CI. It ensures that every change to the codebase is releasable to production at any time. This means that after the build and test phases, the application is automatically prepared for release, often involving packaging and deployment to a staging environment. The final step to production is typically a manual approval.

Continuous Deployment (CD) takes Continuous Delivery a step further by automatically deploying every change that passes all stages of the pipeline to production without human intervention. This requires a very high level of confidence in your automated tests and infrastructure.

Why GitHub Actions?

GitHub Actions is a powerful automation platform that allows you to automate, customize, and execute your software development workflows directly in your repository. It lets you build, test, and deploy your code right from GitHub. Here’s why it’s a game-changer:

  • Native Integration: Seamlessly integrated with GitHub, allowing workflows to react to any GitHub event (pushes, pull requests, issues, releases, etc.).
  • YAML-based Workflows: Easy-to-understand and version-controlled configuration using YAML files.
  • Extensible: A vast marketplace of pre-built actions and the ability to create custom actions for specific needs.
  • Scalable: Supports a wide range of operating systems (Ubuntu, Windows, macOS) and environments with both GitHub-hosted and self-hosted runners.
  • Cost-Effective: Generous free tiers for public and private repositories, making it accessible for individuals and small teams.

GitHub Actions Core Concepts: The Building Blocks

Before we dive into building pipelines, let’s understand the fundamental components of GitHub Actions.

  • Workflow: A configurable automated process that you set up in your repository. Workflows are defined by a YAML file (e.g., .github/workflows/ci.yml) and run when triggered by an event.
  • Event: A specific activity that triggers a workflow. This could be a push to a branch, a pull request being opened, a release published, a scheduled cron job, or even a manual trigger.
  • Job: A set of steps in a workflow that executes on the same runner. Each job runs in a fresh instance of the runner, meaning they are isolated from each other by default. Jobs can run in parallel or sequentially.
  • Step: An individual task within a job. A step can be a shell command, a script, or an action. Steps are executed in the order they are defined.
  • Action: The smallest portable building block of a workflow. An action is a custom application that performs a complex but frequently repeated task. Actions can be written in JavaScript, Docker containers, or composite run steps. You can use actions from the GitHub Marketplace, write your own, or use community-contributed ones.
  • Runner: A server that runs your workflow when it’s triggered. GitHub provides GitHub-hosted runners (Ubuntu, Windows, macOS) with pre-installed tools, or you can host your own self-hosted runners for specific environments or requirements.

A Simple Workflow YAML Structure

Let’s look at the basic structure of a GitHub Actions YAML file:

name: My First Workflow

on: [push, pull_request] # Event that triggers the workflow

jobs: # A collection of jobs
  build: # The ID of the job
    runs-on: ubuntu-latest # The type of runner to use
    steps: # A sequence of steps
      - uses: actions/checkout@v4 # An action to checkout the repository code
      - name: My first step
        run: echo "Hello, GitHub Actions!"
      - name: My second step
        run: |
          echo "This step runs multiple commands."
          echo "And it's pretty cool."

In this example:

  • name: A human-readable name for the workflow.
  • on: Specifies when the workflow runs. Here, it runs on every push and pull_request event.
  • jobs: Contains one or more jobs.
  • build: The ID for our single job.
  • runs-on: Specifies the runner environment. ubuntu-latest is a common choice.
  • steps: A list of tasks for the job.
  • uses: actions/checkout@v4: This is a marketplace action that checks out your repository code into the runner’s workspace.
  • name and run: Define a step that executes a shell command. The | indicates a multi-line string.

Building a Basic CI Pipeline for a Node.js Application

Let’s put these concepts into practice by building a CI pipeline for a typical Node.js application. Our goal is to ensure that every time new code is pushed or a pull request is opened, the application’s dependencies are installed and its tests pass.

Scenario: Node.js App CI

Imagine a simple Node.js project with a package.json, some source files, and a test suite (e.g., using Jest). We want to:

  1. Trigger the CI workflow on push to main and on pull_request to any branch.
  2. Check out the repository code.
  3. Set up the correct Node.js version.
  4. Install npm dependencies.
  5. Run the unit tests.

CI Workflow Code Example

Create a file named .github/workflows/node-ci.yml in your repository:

name: Node.js CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4 # Action to get the code from the repo

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4 # Action to set up Node.js
        with:
          node-version: '20.x' # Specify the Node.js version

      - name: Install dependencies
        run: npm ci # 'npm ci' is preferred over 'npm install' in CI environments
        # 'npm ci' ensures a clean install based on package-lock.json

      - name: Run tests
        run: npm test # Execute the test script defined in package.json

      - name: Archive test reports (optional)
        uses: actions/upload-artifact@v4
        if: always() # Ensure this step runs even if tests fail
        with:
          name: test-reports
          path: ./test-results.xml # Assuming tests output to this file
          retention-days: 5

GitHub Actions: CI/CD from Basics to Matrix Builds
Generated Image

Explanation of the CI Workflow

  • on: push: branches: ["main"]: This workflow will only trigger when code is pushed to the main branch. You can add more branches or use wildcards (e.g., 'feature/*').
  • on: pull_request: branches: ["main"]: This ensures that any pull request targeting the main branch also triggers the CI, providing early feedback before merging.
  • uses: actions/setup-node@v4: This action is specifically designed to set up a Node.js environment. The with: node-version: '20.x' argument tells it which version to install.
  • npm ci: This command performs a clean installation of dependencies. It’s crucial for CI environments because it guarantees that you’re installing the exact versions specified in package-lock.json, preventing inconsistencies.
  • npm test: This command executes the test script defined in your package.json (e.g., "test": "jest"). If any tests fail, this step will exit with a non-zero code, causing the job to fail, and GitHub Actions will report this status.
  • actions/upload-artifact@v4: This optional step demonstrates how to upload artifacts (e.g., test reports, build logs, coverage reports). These artifacts can be downloaded later from the workflow run page, useful for debugging or further analysis. The if: always() ensures the artifact is uploaded even if previous steps fail.

Enhancing CI with Linting and Security Scans

A good CI pipeline goes beyond just running tests. Adding linting and basic security checks can significantly improve code quality and catch potential issues early.

Scenario: Linting and Vulnerability Check

We want to add steps to our Node.js CI pipeline to:

  1. Run a linter (e.g., ESLint) to enforce coding style and catch syntax errors.
  2. Perform a dependency vulnerability scan (e.g., using npm audit).

Enhanced CI Workflow Code Example

name: Node.js CI with Lint & Audit

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint # Assuming you have a "lint" script in package.json (e.g., "eslint .")

      - name: Run dependency audit
        run: npm audit --audit-level=critical # Fails if critical vulnerabilities are found

      - name: Run tests
        run: npm test

      - name: Archive test reports (optional)
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-reports
          path: ./test-results.xml
          retention-days: 5

Explanation of Enhancements

  • npm run lint: This step executes the linting script. Make sure your package.json has an entry like "lint": "eslint .". Linters help maintain code consistency and catch common pitfalls.
  • npm audit --audit-level=critical: This command scans your project’s dependencies for known vulnerabilities. By setting --audit-level=critical, the command will only fail the step if critical vulnerabilities are found. You can adjust the level (low, moderate, high, critical) based on your project’s risk tolerance.

By integrating these steps, your CI pipeline becomes a more robust gatekeeper, ensuring not just functional correctness but also code quality and security hygiene.

Introduction to CD: Continuous Deployment

Once your CI pipeline ensures code quality and correctness, the next logical step is Continuous Delivery or Deployment. This automates the process of getting your tested code into various environments, from staging to production.

What it means for GitHub Actions

In GitHub Actions, a CD pipeline typically involves:

  1. Building artifacts: Compiling code, bundling assets, creating Docker images.
  2. Authenticating: Logging into cloud providers (AWS, Azure, GCP), Docker registries, or SSH servers.
  3. Deploying: Transferring artifacts, updating services, running migration scripts.
  4. Environment Management: Using GitHub Environments for protection rules, secrets, and manual approvals.

Considerations for CD

  • Secrets: Credentials for deployment targets (API keys, SSH keys, cloud credentials) should never be hardcoded. GitHub Secrets provide a secure way to store and access sensitive information within your workflows.
  • Environments: GitHub Environments allow you to define rules for specific deployment environments (e.g., staging, production), such as required reviewers or wait timers, adding an essential layer of control to your CD process.
  • Deployment Strategy: Think about how you want to deploy. Simple copy, Docker image push, serverless function update, etc.

Building a Basic CD Pipeline to Deploy a Static Site

For our CD example, let’s consider a common scenario: building a static site (e.g., a React app, Vue app, or plain HTML/CSS/JS) and deploying it to an S3 bucket configured for static website hosting. This demonstrates building an artifact and then deploying it.

Scenario: Static Site Deployment to S3

We’ll extend our workflow to:

  1. Run CI steps (checkout, setup Node.js, install, test).
  2. Build the static assets (e.g., npm run build).
  3. Deploy the built assets to an AWS S3 bucket, triggered only on pushes to main.

For this, you’ll need:

  • An AWS S3 bucket configured for static website hosting.
  • AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) stored as GitHub Secrets in your repository.

CD Workflow Code Example

Let’s create a separate workflow file for deployment, .github/workflows/deploy-s3.yml. This separation is a common best practice for larger projects.

name: Deploy Static Site to S3

on:
  push:
    branches: [ "main" ] # Deploy only on push to main branch

env:
  # Define environment variables specific to this workflow
  AWS_REGION: us-east-1 # Your AWS region
  S3_BUCKET_NAME: my-static-website-bucket # Your S3 bucket name

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    environment: production # Use a GitHub Environment for protection rules

    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: '20.x'

      - name: Install dependencies
        run: npm ci

      - name: Run tests (optional, but highly recommended before deployment)
        run: npm test

      - name: Build static assets
        run: npm run build # Assuming your package.json has a "build" script

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Deploy to S3
        run: |
          aws s3 sync ./build s3://${{ env.S3_BUCKET_NAME }} --delete # Sync 'build' folder to S3
          aws cloudfront create-invalidation --distribution-id YOUR_CLOUDFRONT_DISTRIBUTION_ID --paths "/*" # Optional: Invalidate CloudFront cache if applicable
        working-directory: ./

GitHub Actions: CI/CD from Basics to Matrix Builds
Generated Image

Explanation of the CD Workflow

  • on: push: branches: ["main"]: The deployment is strictly tied to pushes on the main branch, signifying a production-ready change.
  • environment: production: This line links the job to a GitHub Environment named “production.” You can configure rules for this environment (e.g., requiring manual approval, specific collaborators) under your repository settings -> Environments. This is a crucial security and control feature for production deployments.
  • env:: Defines environment variables that are accessible throughout the workflow. Good for non-sensitive configuration.
  • npm run build: This step compiles your static assets into a distributable folder, typically named build or dist.
  • aws-actions/configure-aws-credentials@v4: A marketplace action that securely configures AWS credentials for subsequent AWS CLI commands. It uses GitHub Secrets for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, ensuring they are not exposed.
  • aws s3 sync ./build s3://${{ env.S3_BUCKET_NAME }} --delete: This command uses the AWS CLI (which is pre-installed on GitHub-hosted runners) to synchronize the contents of your local build directory with your S3 bucket. The --delete flag ensures that files removed from your build are also removed from S3.
  • aws cloudfront create-invalidation ...: If you’re using AWS CloudFront in front of your S3 bucket, this optional command invalidates the cache, ensuring users see the latest deployed version. Remember to replace YOUR_CLOUDFRONT_DISTRIBUTION_ID with your actual ID.

Advanced GitHub Actions Concepts

Beyond basic CI/CD, GitHub Actions offers powerful features for more complex scenarios.

Conditional Workflows, Jobs, and Steps

You can use the if conditional to control when a workflow, job, or step runs. This is incredibly useful for tailoring pipeline behavior.

jobs:
  deploy-staging:
    if: github.ref == 'refs/heads/develop' # Only run if pushing to 'develop' branch
    runs-on: ubuntu-latest
    steps:
      - name: Only deploy to staging from develop
        run: echo "Deploying to staging..."

  test-matrix:
    runs-on: ubuntu-latest
    steps:
      - name: Run step only on Linux
        if: runner.os == 'Linux' # Accessing runner context
        run: echo "This runs on Linux"

Reusable Workflows: DRY Principle

For large organizations or monorepos, you might have common CI/CD logic (e.g., build steps, security scans) repeated across many repositories or applications. Reusable workflows allow you to define a workflow once and call it from multiple other workflows, promoting the DRY (Don’t Repeat Yourself) principle.

Example of a reusable workflow (.github/workflows/reusable-build.yml):

name: Reusable Node.js Build

on:
  workflow_call: # This workflow can be called by other workflows
    inputs:
      node-version:
        required: true
        type: string
        description: 'Node.js version to use'
    outputs:
      build-status:
        description: "Status of the build job"
        value: ${{ jobs.build.status }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
      - name: Indicate build success
        run: echo "Build successful!"

Example of calling a reusable workflow:

name: My App CI

on: [push]

jobs:
  call-build:
    uses: ./.github/workflows/reusable-build.yml # Path to the reusable workflow
    with:
      node-version: '18.x' # Pass input to the reusable workflow
    secrets: inherit # Inherit secrets from the caller workflow

Environment Protection Rules

As mentioned with our S3 deployment, GitHub Environments provide robust control over deployments. You can configure:

  • Required reviewers: Specific people or teams must approve a job before it proceeds.
  • Wait timer: Delay a job for a specified duration.
  • Branch deployment protection rules: Only specific branches can deploy to an environment.

These are configured in your repository settings under “Environments.”

Concurrency

For deployment workflows, you often want to ensure only one deployment runs at a time to prevent race conditions or conflicting changes. The concurrency keyword manages this.

name: Deploy to Production

on:
  push:
    branches: [ main ]

concurrency:
  group: production_deployment # All workflows with this group will be mutually exclusive
  cancel-in-progress: true # Cancel any currently running job in this group

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

If a new push to main occurs while a production_deployment workflow is already running, the new workflow will wait, or if cancel-in-progress: true is set, the old workflow will be canceled, and the new one will start.

Caching

Dependencies (like node_modules or Maven artifacts) can take a long time to download. Caching them can significantly speed up subsequent workflow runs.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: ~/.npm # Path to cache
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} # Unique key for cache
          restore-keys: |
            ${{ runner.os }}-node-
      - name: Install dependencies
        run: npm ci

The key is crucial; it should change only when the dependencies change (e.g., package-lock.json). restore-keys provides fallback keys if the primary key doesn’t match.

Artifacts

We briefly touched on artifacts. They are files or directories generated by a workflow that you want to persist after the workflow run finishes. They are useful for passing data between jobs, debugging, or storing build outputs.

  • actions/upload-artifact@v4: To upload files.
  • actions/download-artifact@v4: To download files in a subsequent job.
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Generate report
        run: echo "Test report content" > report.txt
      - name: Upload report
        uses: actions/upload-artifact@v4
        with:
          name: my-report
          path: report.txt

  deploy:
    runs-on: ubuntu-latest
    needs: build # This job depends on 'build'
    steps:
      - name: Download report
        uses: actions/download-artifact@v4
        with:
          name: my-report
      - name: Use report
        run: cat report.txt

Deep Dive into Matrix Builds: Test Everywhere

One of the most powerful features of GitHub Actions, especially for libraries, frameworks, or multi-platform applications, is the matrix build strategy. It allows you to run multiple jobs concurrently based on a combination of different variables.

What are Matrix Builds and Why Use Them?

A matrix build generates multiple job configurations dynamically. Instead of writing separate jobs for each combination of environment variables, you define a matrix, and GitHub Actions creates a job for each permutation.

Why use them?

  • Cross-Environment Testing: Test your application against different versions of Node.js, Python, Ruby, Java, etc.
  • Cross-OS Testing: Verify compatibility across Linux, Windows, and macOS.
  • Browser Compatibility: If running UI tests, test across different browsers (though this often involves external services).
  • Reduced Configuration: Avoid repetitive YAML by defining variables once.
  • Faster Feedback: Jobs in a matrix run in parallel, providing quicker feedback on compatibility issues.

Syntax and Examples

The strategy.matrix keyword is used to define a matrix. You provide a map of variables, and GitHub Actions creates a job for each combination.

Basic Matrix Example: Node.js Versions

Let’s modify our Node.js CI workflow to test against multiple Node.js versions (e.g., 18, 20, 22).

name: Node.js Matrix CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22] # Define the Node.js versions to test against

    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }} # Use the current matrix value

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

This workflow will now create three separate jobs, each running the build and test steps with a different Node.js version. They will all run in parallel.

Multi-Dimensional

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Kubernetes Production Patterns for Real-Time Applications

Sep 28, 2024 · 2 min read

Observability with Prometheus, Grafana & OpenTelemetry

Jul 03, 2026 · 15 min read

GitHub Actions: CI/CD from Basics to Matrix Builds

Jul 20, 2026 · 16 min read