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, caching, deployments, and security.

Khader Vali July 20, 2026 16 min read

In the fast-paced world of software development, delivering high-quality software quickly and reliably is paramount. This is where Continuous Integration (CI) and Continuous Delivery/Deployment (CD) come into play, forming the backbone of modern DevOps practices. For developers and teams utilizing GitHub, GitHub Actions provides a powerful, native, and highly flexible platform to automate virtually any part of your software development workflow.

As a senior software engineer and technical writer, I’ve seen firsthand how well-implemented CI/CD pipelines can transform development cycles, reduce bugs, and accelerate time-to-market. In this comprehensive guide, we’ll embark on a journey through GitHub Actions, starting from the fundamental concepts of CI/CD and progressing to advanced topics like matrix builds, environment management, and reusable workflows. Whether you’re new to CI/CD or looking to optimize your existing GitHub Actions pipelines, you’ll find actionable insights and practical code examples here.

Our goal is to not just show you how to write YAML files, but to understand the “why” behind each configuration, enabling you to design robust, efficient, and secure pipelines tailored to your project’s needs. Let’s dive in!

Understanding CI/CD: The Foundation of Modern DevOps

Before we delve into the specifics of GitHub Actions, it’s crucial to grasp the core principles of Continuous Integration and Continuous Delivery/Deployment.

What is Continuous Integration (CI)?

Continuous Integration (CI) is a development practice where developers frequently merge their code changes into a central repository. Instead of building features in isolation for weeks and merging them all at once, CI encourages small, frequent commits. Each merge then automatically triggers an automated build and test process.

The primary goals of CI are:

  • Early Detection of Issues: By integrating and testing frequently, conflicts and bugs are caught early, when they are easier and cheaper to fix.
  • Improved Code Quality: Automated tests ensure that new changes don’t break existing functionality, maintaining a high standard of code quality.
  • Reduced Integration Hell: Smaller, more frequent merges minimize complex merge conflicts that can plague large, infrequent integrations.
  • Faster Feedback: Developers receive immediate feedback on their changes, allowing them to quickly iterate and correct mistakes.

What is Continuous Delivery (CD) and Continuous Deployment (CD)?

While often used interchangeably, Continuous Delivery and Continuous Deployment have distinct meanings:

  • Continuous Delivery (CD): This is an extension of CI. After the build and automated tests pass, Continuous Delivery ensures that the software is always in a release-ready state. This means it’s ready to be deployed to production at any time, though the actual deployment is a manual step, typically triggered by a human. The focus is on making deployment easy, reliable, and low-risk.
  • Continuous Deployment (CD): This takes Continuous Delivery a step further. Every change that passes the automated tests and quality gates is automatically deployed to production without human intervention. This requires a high degree of confidence in your automated testing and monitoring strategy. The goal is to achieve the fastest possible release cycle.

In essence, CI is about getting code into a shared repository frequently and ensuring it works. CD is about getting that working code ready for production (Continuous Delivery) or pushing it directly to production (Continuous Deployment).

The CI/CD Pipeline: An Architectural Overview

A CI/CD pipeline is a series of automated steps that your code goes through from commit to deployment. Think of it as an assembly line for your software. Here’s a typical flow represented as a word diagram:


[Developer]
    |
    | (1. Pushes Code to Git Repository)
    V
[GitHub Repository]
    |
    | (2. Git Push Event Triggers Workflow)
    V
[GitHub Actions Service]
    |
    | (3. Workflow Execution Initiated)
    V
[GitHub Action Runner (e.g., Ubuntu, Windows, macOS)]
    |
    |--- [Job: Build] -------------------------------------
    |    | (4a. Checkout Code)
    |    | (4b. Install Dependencies)
    |    | (4c. Compile Code / Build Artifacts)
    |    V
    |   [Artifacts Stored (e.g., WAR, JAR, Docker Image)]
    |
    |--- [Job: Test] --------------------------------------
    |    | (5a. Checkout Code)
    |    | (5b. Install Dependencies)
    |    | (5c. Run Unit Tests, Integration Tests)
    |    V
    |   [Test Reports & Code Coverage]
    |
    |--- [Job: Static Analysis / Linting] -----------------
    |    | (6a. Checkout Code)
    |    | (6b. Run Linter/Static Code Analyzer)
    |    V
    |   [Code Quality Reports]
    |
    | (7. If All CI Jobs Pass)
    V
[GitHub Action Runner (often a different one for CD)]
    |
    |--- [Job: Deploy to Staging] --------------------------
    |    | (8a. Fetch Artifacts from Build Job)
    |    | (8b. Authenticate to Staging Environment)
    |    | (8c. Deploy Artifacts to Staging Server/Cloud)
    |    | (8d. Run E2E Tests on Staging)
    |    V
    |   [Staging Environment Live]
    |
    | (9. If Staging Deployment & Tests Pass, potentially Manual Approval)
    V
[GitHub Action Runner]
    |
    |--- [Job: Deploy to Production] -----------------------
    |    | (10a. Fetch Artifacts)
    |    | (10b. Authenticate to Production Environment)
    |    | (10c. Deploy Artifacts to Production)
    |    | (10d. Post-Deployment Smoke Tests)
    |    | (10e. Notify Teams (Slack/Email))
    |    V
    |   [Production Environment Live]

This diagram illustrates the sequential and sometimes parallel nature of CI/CD. Each box represents a distinct stage or component, with GitHub Actions serving as the orchestrator.

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

Introducing GitHub Actions: Your Automation Engine

GitHub Actions is an event-driven automation platform built directly into GitHub. It allows you to automate, customize, and execute your software development workflows right in your repository. You can build, test, and deploy your code directly from GitHub, and much more.

Key Components of GitHub Actions

To effectively use GitHub Actions, it’s essential to understand its core building blocks:

  1. Workflows: A workflow is an automated procedure that you add to your repository. Workflows are defined by a YAML file (`.github/workflows/*.yml`) and are triggered by events in your repository, such as a push, pull request, or even a scheduled time. Each repository can have multiple workflows.
  2. Events: An event is a specific activity in a repository that triggers a workflow. Examples include pushing code, creating a pull request, issuing a release, or even a cron schedule.
  3. Jobs: A job is a set of steps that execute on the same runner. Each job runs in a fresh instance of the virtual environment. You can configure a job to depend on other jobs, meaning they run sequentially, or run jobs in parallel if there are no dependencies.
  4. Steps: A step is an individual task within a job. Steps can run commands (like npm install), execute a script, or run an action. Steps are executed in the order they are defined.
  5. Actions: An action is a reusable unit of work. Actions are the smallest portable building block of a workflow. You can write your own actions, or use actions created by the GitHub community. For example, actions/checkout@v4 is an action to check out your repository code.
  6. Runners: A runner is a server that runs your workflows. GitHub provides hosted runners (Ubuntu, Windows, macOS) which are pre-configured with a variety of tools and environments. You can also host your own self-hosted runners for specific environments or hardware requirements.

The Workflow YAML Structure

GitHub Actions workflows are defined in YAML files located in the .github/workflows/ directory of your repository. A typical workflow file looks like this:


# .github/workflows/ci.yml

name: My CI Pipeline

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - main
      - develop

jobs:
  build-and-test:
    runs-on: ubuntu-latest # Specifies the runner environment
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4 # Uses a pre-built action to checkout the repo

      - name: Setup Node.js
        uses: actions/setup-node@v4 # Uses an action to set up Node.js
        with:
          node-version: '18' # Specifies the Node.js version

      - name: Install Dependencies
        run: npm ci # Runs a command line instruction

      - name: Run Tests
        run: npm test

  another-job:
    needs: build-and-test # This job will run only after 'build-and-test' completes successfully
    runs-on: ubuntu-latest
    steps:
      - name: Echo a message
        run: echo "Build and test completed, now doing something else."

This basic structure forms the template for all your GitHub Actions workflows. Let’s break it down further with practical examples.

Building a Basic CI Pipeline: Hello World

Let’s start with a simple CI pipeline for a Node.js application. This pipeline will checkout the code, install dependencies, and run tests.

Scenario: Basic Node.js App

Imagine you have a simple Node.js project with a package.json and some unit tests defined, e.g., using Jest. Your project structure might look like this:


my-node-app/
├── .github/
│   └── workflows/
│       └── ci.yml
├── src/
│   └── index.js
├── tests/
│   └── index.test.js
├── package.json
├── package-lock.json

And package.json might have a script for testing:


{
  "name": "my-node-app",
  "version": "1.0.0",
  "description": "A simple Node.js application",
  "main": "src/index.js",
  "scripts": {
    "test": "jest",
    "start": "node src/index.js"
  },
  "devDependencies": {
    "jest": "^29.7.0"
  }
}

Workflow Setup: The ci.yml File

Create a file named .github/workflows/ci.yml with the following content:


# .github/workflows/ci.yml
name: Node.js CI

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

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

      - name: Set up Node.js environment
        uses: actions/setup-node@v4
        with:
          node-version: '18' # Use Node.js version 18

      - name: Install dependencies
        run: npm ci # 'npm ci' is preferred over 'npm install' in CI environments for speed and reliability

      - name: Run unit tests
        run: npm test

Let’s dissect this workflow:

  • name: Node.js CI: A user-friendly name for your workflow that appears in the GitHub Actions UI.
  • on:: Defines when the workflow should run. Here, it triggers on push and pull_request events to the main and develop branches.
  • jobs:: Contains one or more jobs. Our first job is named build-and-test.
  • runs-on: ubuntu-latest: Specifies that this job should run on a fresh virtual machine instance of Ubuntu, using the latest available version. GitHub provides other runners like windows-latest and macos-latest.
  • steps:: A sequence of tasks to be executed within the build-and-test job.
    • uses: actions/checkout@v4: This step uses a pre-built GitHub Action to clone your repository’s code onto the runner. It’s almost always the first step in any workflow.
    • uses: actions/setup-node@v4: This action sets up a Node.js environment. We specify node-version: '18' to ensure consistency.
    • run: npm ci: This step executes a command-line instruction. npm ci is similar to npm install but is designed specifically for CI environments. It installs dependencies directly from package-lock.json (or npm-shrinkwrap.json), ensuring reproducible builds and failing if the lockfile is out of sync with package.json.
    • run: npm test: Finally, this step runs your project’s tests, assuming you have a test script defined in your package.json.

With this file committed and pushed to your GitHub repository, any push or pull request to main or develop will automatically trigger this CI pipeline, providing immediate feedback on your code’s build and test status.

Enhancing CI: Caching and Linting

To make our CI pipeline more efficient and robust, we can add caching for dependencies and integrate code quality checks like linting.

Caching Dependencies for Faster Builds

Installing dependencies (e.g., via npm ci) can be time-consuming, especially for large projects. GitHub Actions allows you to cache dependencies to speed up subsequent workflow runs.


# .github/workflows/ci.yml
name: Node.js CI with Caching

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

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

      - name: Set up Node.js environment
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Cache Node.js modules
        uses: actions/cache@v4 # Use the caching action
        with:
          path: ~/.npm # Path to cache (npm's cache directory)
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} # Cache key based on OS and package-lock.json
          restore-keys: |
            ${{ runner.os }}-node- # Restore key for broader matches

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm test

Here, we’ve added a new step using actions/cache@v4:

  • path: ~/.npm: This is the directory where npm stores its global cache.
  • key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}: This is the cache key. It’s a combination of the operating system (runner.os), ‘node’, and a hash of your package-lock.json file. If package-lock.json changes, a new cache will be created.
  • restore-keys: | ${{ runner.os }}-node-: These are fallback keys. If the exact key isn’t found, GitHub Actions will try to find a cache that matches these prefixes. This helps in finding a partially matching cache, improving cache hit rates.

On subsequent runs, if the package-lock.json hasn’t changed, GitHub Actions will restore the cached modules instead of downloading them again, significantly reducing the build time.

Integrating Linting for Code Quality

Linting enforces coding standards and catches potential errors early. Let’s add an ESLint step to our pipeline.

First, ensure your project has ESLint configured (e.g., with .eslintrc.js and a lint script in package.json like "lint": "eslint .").


# .github/workflows/ci.yml
name: Node.js CI with Caching and Linting

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

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

      - name: Set up Node.js environment
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint # Assuming you have a "lint" script in package.json

      - name: Run unit tests
        run: npm test

By adding npm run lint before running tests, you ensure that your code adheres to defined style guides and catches syntax errors or potential issues even before testing, providing faster feedback to developers.

Introducing CD: Deployment to a Simple Environment

Now that our CI pipeline is robust, let’s extend it to include a Continuous Delivery step – deploying our application to a simple environment. For demonstration, we’ll simulate a deployment to a hypothetical static hosting service or a server via a simple script. In real-world scenarios, this would involve cloud provider-specific actions (e.g., AWS S3, Azure App Service, Google Cloud Run) or SSH/SFTP for traditional servers.

Scenario: Deploying a Static Build

Let’s assume our Node.js application builds into a static set of files (e.g., a React app, Vue app, or just generated HTML/CSS/JS) into a dist/ directory. We want to deploy this dist/ directory to a web server.

Security Considerations with Secrets

Deployment often requires credentials (API keys, passwords, SSH keys). GitHub Actions provides a secure way to store these as “Secrets” in your repository settings. Never hardcode credentials in your workflow files!

  1. Go to your GitHub repository.
  2. Navigate to “Settings” -> “Secrets and variables” -> “Actions” -> “New repository secret”.
  3. Add a secret, for example, DEPLOY_HOST, DEPLOY_USERNAME, and DEPLOY_KEY (for SSH).

The Deployment Workflow

We’ll add a new job named deploy that runs only after build-and-test succeeds and only on pushes to the main branch.


# .github/workflows/deploy.yml (It's often good practice to separate CI and CD workflows)
name: Node.js CD - Deploy

on:
  push:
    branches: [ "main" ] # Only deploy from main branch

jobs:
  build-and-test: # Same CI job as before, potentially moved to a reusable workflow later
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository code
        uses: actions/checkout@v4

      - name: Set up Node.js environment
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run unit tests
        run: npm test

      - name: Build application (e.g., for production)
        run: npm run build # Assuming 'npm run build' generates static assets in 'dist/'

      - name: Upload build artifact
        uses: actions/upload-artifact@v4 # Uploads the 'dist' directory as an artifact
        with:
          name: my-app-build
          path: dist/ # Path to your build output

  deploy:
    needs: build-and-test # This job depends on the successful completion of build-and-test
    runs-on: ubuntu-latest
    if: success() && github.ref == 'refs/heads/main' # Ensure deployment only if CI passes and on main branch

    environment: # Define an environment for deployment (more on this later)
      name: Production
      url: https://my-app.khadervali.com

    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: my-app-build
          path: ./dist-downloaded # Download artifact to this path

      - name: Set up SSH agent
        uses: webfactory/ssh-agent@v0.9.0 # Action to set up SSH for deployment
        with:
          ssh-private-key: ${{ secrets.DEPLOY_KEY }} # Use our secret for the SSH private key

      - name: Deploy to Server via Rsync
        run: |
          # Use rsync for efficient file transfer
          # Ensure your DEPLOY_HOST and DEPLOY_USERNAME are set as secrets
          echo "Deploying to ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_HOST }}"
          rsync -avz --delete ./dist-downloaded/ ${{ secrets.DEPLOY_USERNAME }}@${{ secrets.DEPLOY_HOST }}:/var/www/my-app/
        env:
          # SSH_ASKPASS is often used with ssh-agent
          SSH_ASKPASS: /usr/bin/ssh-askpass

Key additions for the deployment workflow:

  • branches: [ "main" ]: The deploy.yml workflow is triggered only on pushes to the main branch.
  • needs: build-and-test: The deploy job will only start if the build-and-test job completes successfully.
  • if: success() && github.ref == 'refs/heads/main': An explicit condition to ensure deployment only happens if the previous job succeeded and the current branch is indeed main.
  • environment: Production: This links the job to a GitHub environment named “Production”. Environments allow you to configure protection rules (like manual approvals or specific branches) and track deployments. The url field provides a link to the deployed application.
  • npm run build: A new step in the build-and-test job to compile your application for production.
  • actions/upload-artifact@v4: This action takes the output of the build (e.g., the dist/ directory) and makes it available as an artifact.
  • actions/download-artifact@v4: In the deploy job, we download the artifact created by the build-and-test job. This ensures that the exact same build that passed CI is deployed, preventing “works on my machine” issues.
  • webfactory/ssh-agent@v0.9.0: A community action to set up an SSH agent on the runner, allowing secure SSH connections without exposing the private key directly.
  • rsync command: A common Unix utility for synchronizing files. Here, it transfers the built application files to a remote server using SSH. The credentials come from GitHub Secrets.

This setup creates a basic yet functional CI/CD pipeline, taking code from commit to deployment on a production-like environment.

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

Advanced CI/CD with GitHub Actions

GitHub Actions offers a wealth of features to handle more complex scenarios. Let’s explore some of them.

Matrix Builds: Testing Across Multiple Configurations

One of the most powerful features for robust testing is the matrix strategy. It allows you to run multiple jobs in parallel, each with a different set of variables. This is invaluable for testing your application or library across various operating systems, language versions, or other configuration parameters.

Why Use Matrix Builds?

  • Cross-Platform Compatibility: Test your code on Ubuntu, Windows, and macOS.
  • Language Version Support: Ensure your library works with Node.js 14, 16, 18, and 20.
  • Multiple Dependencies:
Written by

Khader Vali

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

Share this article

Related Articles

Enhance Software Supply Chain Security: SBOM & Sigstore

Jul 26, 2026 · 18 min read

GitHub Actions CI/CD: From Basics to Matrix Builds

Jul 23, 2026 · 15 min read

Kubernetes Production Patterns for Real-Time Applications

Sep 28, 2024 · 2 min read