DevOps

GitHub Actions CI/CD: From Basics to Matrix Builds

Master GitHub Actions for CI/CD. Learn to build pipelines from basic syntax, events, and jobs to advanced features like caching, artifacts, and matrix builds.

Khader Vali July 23, 2026 15 min read

Building CI/CD Pipelines with GitHub Actions: From Basics to Matrix Builds

As a senior software engineer, I’ve seen firsthand how crucial efficient Continuous Integration and Continuous Delivery (CI/CD) pipelines are for modern software development. They are the backbone of rapid, reliable, and high-quality software releases. Among the myriad of CI/CD tools available today, GitHub Actions has emerged as a powerhouse, seamlessly integrating with your code repository and offering incredible flexibility.

At Khadervali.com, we believe in empowering developers with the knowledge to build robust systems. In this comprehensive guide, we’ll embark on a journey through GitHub Actions, starting from the absolute fundamentals and progressively diving into advanced concepts 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.

The CI/CD Imperative and GitHub Actions’ Role

Before we delve into the mechanics, let’s briefly revisit why CI/CD is non-negotiable in today’s fast-paced development landscape.

What is CI/CD?

  • Continuous Integration (CI): This practice involves developers frequently merging their code changes into a central repository. Automated builds and tests are run to detect integration issues early and quickly. The goal is to ensure the codebase is always in a releasable state.
  • Continuous Delivery (CD): Building on CI, Continuous Delivery ensures that all code changes are automatically built, tested, and prepared for release to a production environment. It guarantees that you can release new changes to your customers rapidly and sustainably.
  • Continuous Deployment (CD – extended): This takes Continuous Delivery a step further by automatically deploying every change that passes all tests to production, without human intervention.

The benefits are immense: faster release cycles, reduced risk of integration bugs, higher code quality, better collaboration, and ultimately, happier users.

Why GitHub Actions?

GitHub Actions brings CI/CD directly into your GitHub repository, offering a host of advantages:

  • Native Integration: Seamlessly integrated with GitHub repositories, pull requests, and issues. No external tools or complex setups needed for basic functionality.
  • YAML-based Workflows: Define your pipelines in simple, human-readable YAML files, version-controlled alongside your code.
  • Vast Marketplace: A rich ecosystem of community-contributed actions for almost any task imaginable, from setting up environments to deploying to cloud providers.
  • Flexible and Extensible: Supports multiple languages, operating systems, and deployment targets. You can also create your own custom actions.
  • Generous Free Tier: GitHub offers a significant free tier for public repositories, making it accessible for open-source projects and personal use.

In essence, GitHub Actions transforms your repository into a powerful automation engine, allowing you to automate virtually any software development workflow.

GitHub Actions Fundamentals: The Building Blocks

To grasp GitHub Actions, we need to understand its core components. Think of these as the Lego bricks you’ll use to construct your pipelines.

The GitHub Actions Architecture (in words)

Imagine your GitHub repository as the central hub.


[Your GitHub Repository]
    |
    |---- .github/workflows/
    |           |
    |           +---- my-ci-workflow.yml  (A Workflow)
    |           |       |
    |           |       +---- Triggered by an Event (e.g., push to main)
    |           |       |
    |           |       +---- Defines multiple Jobs (e.g., build, test, deploy)
    |           |                   |
    |           |                   +---- Job 1 (e.g., build-app)
    |           |                   |       |
    |           |                   |       +---- Runs on a Runner (e.g., ubuntu-latest)
    |           |                   |       |
    |           |                   |       +---- Consists of multiple Steps
    |           |                   |                   |
    |           |                   |                   +---- Step 1 (e.g., uses: actions/checkout@v4)
    |           |                   |                   +---- Step 2 (e.g., run: npm install)
    |           |                   |                   +---- Step 3 (e.g., run: npm test)
    |           |                   |
    |           |                   +---- Job 2 (e.g., deploy-app)
    |           |                           |
    |           |                           +---- Might depend on Job 1 (needs: build-app)
    |           |                           +---- Runs on another Runner
    |           |                           +---- Consists of its own Steps
    |           |
    |           +---- another-cd-workflow.yml (Another Workflow)

Let’s break down these terms:

  • Workflow: This is the top-level automation process. A workflow is a configurable automated process that you set up in your repository. You define workflows in YAML files within the .github/workflows/ directory. Each workflow is triggered by an event.
  • Event: An activity that triggers a workflow. This could be a push to a branch, a pull request being opened, a scheduled time, a manual trigger (workflow_dispatch), or many others.
  • Job: A set of steps that execute on the same runner. A workflow can have one or more jobs, and they run in parallel by default, but you can configure them to run sequentially using dependencies.
  • Step: An individual task within a job. A step can either run a command (e.g., npm install) or execute an action (e.g., actions/checkout@v4).
  • Action: A reusable unit of work. Actions are the smallest portable building block of a workflow. You can write your own, or use actions from the GitHub Marketplace. An action can be a Docker container, a JavaScript file, or a composite run steps action.
  • Runner: A server that runs your workflow when it’s triggered. GitHub provides GitHub-hosted runners (virtual machines with pre-installed software for various OSs like Ubuntu, Windows, macOS). You can also use self-hosted runners for custom environments or on-premise infrastructure.

Understanding these concepts is key to writing effective GitHub Actions workflows.

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

Building Your First CI Pipeline (Hello World)

Let’s create a simple CI workflow for a Node.js project. This workflow will:

  1. Trigger on every push to the main branch and every pull request targeting main.
  2. Checkout the code.
  3. Set up Node.js.
  4. Install dependencies.
  5. Run tests.

First, ensure you have a simple Node.js project with a package.json and some tests (e.g., using Jest or Mocha). For simplicity, let’s assume a basic project with a test script in package.json:

{
  "name": "my-node-app",
  "version": "1.0.0",
  "description": "A simple Node.js app",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Running tests... Success!\" && exit 0"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Now, 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
      uses: actions/checkout@v4

    - name: Set up Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20' # Specify the Node.js version

    - name: Install dependencies
      run: npm ci # 'ci' is preferred over 'install' in CI environments for clean installs

    - name: Run tests
      run: npm test

Let’s break down this first workflow:

  • name: Node.js CI: The name of your workflow, visible in the GitHub Actions tab.
  • on:: Defines when the workflow runs. Here, it runs on push and pull_request events targeting the main branch.
  • jobs:: Contains all jobs in this workflow. We have one job named build.
  • build:: The identifier for our job.
  • runs-on: ubuntu-latest: Specifies the runner environment. ubuntu-latest is a GitHub-hosted runner running the latest Ubuntu OS.
  • steps:: An ordered list of tasks for the build job.
    • - name: Checkout repository: A descriptive name for the step.
    • uses: actions/checkout@v4: This uses a pre-built action from the GitHub Marketplace to checkout your repository’s code. The @v4 specifies the version.
    • - name: Set up Node.js: Another descriptive name.
    • uses: actions/setup-node@v4: This action sets up a Node.js environment.
    • with: node-version: '20': This passes an input parameter to the setup-node action, specifying Node.js version 20.
    • - name: Install dependencies: Runs a shell command.
    • run: npm ci: Executes the npm ci command.
    • - name: Run tests: Another shell command step.
    • run: npm test: Executes the npm test command.

Commit this file to your repository and push it to main. Navigate to the “Actions” tab in your GitHub repository, and you’ll see your workflow running!

Deep Dive into Core Concepts

Now that we have a basic workflow, let’s explore the underlying concepts in more detail.

Events: What Triggers a Workflow?

The on: keyword is crucial. GitHub Actions supports a vast array of events:

  • push: When code is pushed to a branch. You can filter by branches (branches: [ main ]) or tags (tags: [ v1.* ]).
  • pull_request: When a pull request is opened, synchronized, or reopened.
  • schedule: Run workflows at specific UTC times using cron syntax (e.g., cron: '0 0 * * *' for daily at midnight).
  • workflow_dispatch: Allows you to manually trigger a workflow from the GitHub UI, API, or GitHub CLI. You can also define input parameters for manual triggers.
  • workflow_call: Enables reusability by allowing one workflow to be called from another.
  • repository_dispatch: Allows you to trigger a workflow from an external system.
  • Many more, including issues, issue_comment, release, fork, etc.

For a full list, refer to the GitHub Actions documentation.

Jobs: Orchestrating Your Pipeline

Jobs are the independent units of work. Here’s more about them:

  • Dependencies (`needs:`): By default, jobs run in parallel. To make them run sequentially, use the needs keyword. A job with needs will only run after all jobs it depends on have completed successfully.

    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps: ...
    
      test:
        runs-on: ubuntu-latest
        needs: build # This job will only run after 'build' completes
        steps: ...
    
      deploy:
        runs-on: ubuntu-latest
        needs: [build, test] # This job will only run after both 'build' and 'test' complete
        steps: ...
            
  • Outputs: Jobs can pass outputs to dependent jobs. You define outputs using the outputs keyword within a job.

    
    jobs:
      build:
        runs-on: ubuntu-latest
        outputs:
          build_id: ${{ steps.generate_id.outputs.id }} # 'generate_id' is a step id
        steps:
        - id: generate_id
          run: echo "id=$(date +%s)" >> "$GITHUB_OUTPUT"
    
      deploy:
        runs-on: ubuntu-latest
        needs: build
        steps:
        - run: echo "Deploying build ID: ${{ needs.build.outputs.build_id }}"
            
  • Concurrency: For workflows that might run frequently (e.g., on every push), you might want to limit concurrent runs to avoid resource contention or accidental overlapping deployments.

    
    concurrency:
      group: production-deployment # All workflows in this group will be concurrent
      cancel-in-progress: true # Cancel any currently running workflow in this group
            

Steps: The Granular Tasks

Steps are the heart of a job. Each step has a name and either a run command or a uses action.

  • `run:`: Executes shell commands. You can specify a shell (e.g., shell: bash, shell: pwsh). Multi-line commands are common.

    
    - name: Run multi-line script
      run: |
        echo "Hello, world!"
        echo "This is a multi-line script."
            
  • `uses:`: Executes a registered action. This is how you leverage the power of the GitHub Marketplace or your own custom actions.

    
    - uses: actions/checkout@v4
    - uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}
            

    Note how with is used to pass inputs to the action.

  • `id:`: Assigns a unique identifier to a step, allowing you to reference its outputs later within the same job.

    
    - name: Get current time
      id: current-time
      run: echo "time=$(date +%H:%M:%S)" >> "$GITHUB_OUTPUT"
    - name: Display time
      run: echo "The current time is ${{ steps.current-time.outputs.time }}"
            
  • `if:`: Conditionally executes a step based on an expression.

    
    - name: Deploy to production
      if: github.ref == 'refs/heads/main' && github.event_name == 'push'
      run: echo "Deploying to production..."
            
  • `env:`: Sets environment variables for a specific step. You can also set them at the job or workflow level.

    
    - name: Run with custom env
      env:
        MY_VARIABLE: 'my-value'
      run: echo "My variable is $MY_VARIABLE"
            

Contexts, Expressions, and Secrets

These allow your workflows to be dynamic and secure.

  • Contexts: Objects that contain information about the workflow run, environment, job, steps, and more. Common contexts include:

    • github: Information about the event and repository (e.g., github.event_name, github.ref, github.sha).
    • env: Environment variables.
    • job: Information about the current job.
    • steps: Outputs of previous steps in the same job.
    • runner: Information about the runner executing the job.
    • secrets: Access to repository or environment secrets.
  • Expressions: Used to evaluate conditions or set dynamic values. They are written using ${{ <expression> }} syntax.

    
    if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'bugfix') }}
            
  • Secrets: Encrypted environment variables that you create in a repository, organization, or environment. They are crucial for handling sensitive information like API keys, tokens, or credentials.

    
    - name: Deploy to cloud
      run: deploy_script.sh
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            

    Secrets are never exposed in logs.

Enhancing CI for Real-World Scenarios

A basic CI workflow is a start, but real-world projects demand more. Let’s look at common enhancements.

Caching Dependencies: Speeding Up Builds

Installing dependencies (e.g., npm install, pip install) can be time-consuming. GitHub Actions provides a cache action to store and restore dependencies, significantly reducing build times for subsequent runs.


name: Node.js CI with Caching

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

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4

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

    - name: Cache Node.js modules
      id: cache-npm
      uses: actions/cache@v4
      with:
        path: ~/.npm # Path to the cache directory (e.g., .npm for npm)
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} # Unique key based on OS and lock file
        restore-keys: |
          ${{ runner.os }}-node- # Fallback key if exact match not found

    - name: Install dependencies
      if: steps.cache-npm.outputs.cache-hit != 'true' # Only run if cache was missed
      run: npm ci

    - name: Run tests
      run: npm test

The actions/cache action works by creating a cache with a key. If a cache with that key exists, it’s restored. Otherwise, the steps after the cache are executed, and a new cache is saved using the specified key.

Linting & Static Analysis: Ensuring Code Quality

Automating code quality checks catches issues early. You can integrate linters (ESLint, Prettier, Black, Flake8) and static analyzers into your CI.


# ... (existing CI workflow) ...

    - name: Install Linter (ESLint example)
      run: npm install eslint

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

Many actions exist in the Marketplace for specific linters or general code quality checks. For example, super-linter/super-linter is a popular choice.

Security Scanning: Early Vulnerability Detection

Integrating basic security scanning (SAST – Static Application Security Testing) helps identify vulnerabilities in your code or dependencies early. GitHub itself offers Dependabot for dependency scanning, and you can integrate more advanced tools.


# ... (existing CI workflow) ...

    - name: Run Snyk vulnerability scan (example)
      uses: snyk/actions/node@master
      env:
        SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} # Snyk API token as a secret
      with:
        command: test

Artifacts: Sharing Build Outputs

Sometimes you need to store files generated during a CI job (e.g., compiled binaries, test reports, built web assets) and make them available to other jobs or for download. This is where artifacts come in.


name: Build and Upload Artifact

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
    - run: npm ci
    - run: npm run build # Assuming this creates a 'dist' directory

    - name: Upload build artifact
      uses: actions/upload-artifact@v4
      with:
        name: my-app-build
        path: dist/ # Path to the directory or file to upload

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - name: Download build artifact
      uses: actions/download-artifact@v4
      with:
        name: my-app-build
        path: ./downloaded-dist # The artifact will be downloaded here

    - name: Deploy downloaded artifact
      run: ls -l ./downloaded-dist # Verify the downloaded files
      # Add deployment logic here, e.g., push to S3, deploy to server

Transitioning to CD: Deployment Strategies

Continuous Delivery picks up where CI leaves off. While CI ensures your code is always buildable and testable, CD ensures it’s always ready for deployment.

Key CD Concepts:

  • Environments: GitHub allows you to define environments (e.g., staging, production). Environments can have specific protection rules (manual approval, required reviewers) and secrets, providing an extra layer of security and control for deployments.
  • Deployment Triggers: Deployments can be triggered automatically (e.g., every merge to main deploys to staging) or manually (e.g., a workflow_dispatch event for production).
  • Rollbacks: A robust CD pipeline should account for failed deployments and provide mechanisms to quickly roll back to a previous stable version.

Building Your First CD Pipeline

Let’s extend our Node.js example to include a simple deployment to a hypothetical server or a cloud service like Netlify/Vercel (for static sites) or an S3 bucket. For demonstration, we’ll simulate a deployment using a shell script and leverage GitHub environments.

First, set up an environment in your GitHub repository:

  1. Go to your repository settings.
  2. Click on “Environments” in the left sidebar.
  3. Click “New environment” and name it, for example, production.
  4. You can add “Required reviewers” or “Wait timer” rules here for production deployments.
  5. Add any environment-specific secrets (e.g., DEPLOY_HOST, DEPLOY_USERNAME, DEPLOY_PASSWORD) to this environment.

Now, let’s modify our workflow:


name: Node.js CI/CD

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch: # Allow manual trigger for production deployment

jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact_path: ${{ steps.build_project.outputs.build_path }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
if: steps.cache-npm.outputs.cache-hit != 'true'
run: npm ci

- name: Run tests
run: npm test

- name: Build project
id: build_project
run: |
npm run build # Assumes 'npm run build' generates static assets in 'dist'
echo "build_path=dist" >> "$GITHUB_OUTPUT" # Output the path to the artifact

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: my-app-build
path: dist/

deploy-staging:
runs-on: ubuntu-latest
needs: build
environment:
name: staging # Associate with the 'staging' environment (if defined)
url: https://staging.khadervali.com # Optional: URL to monitor deployment
if: github.ref == 'refs/heads/main' # Only deploy to staging on pushes to main

steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: my-app-build
path: ./app-to-deploy

- name: Simulate deployment to staging
run: |
echo "Deploying to staging environment..."
ls -l ./app-to-deploy # Verify content
# Example: rsync -avzh ./app-to-deploy/Deploy-to-Staging-Server-Address
echo "Deployment to staging complete!"

deploy-production:
runs-on: ubuntu-latest
needs: [build, deploy-staging] # Depends on build and staging deployment success
environment:
name: production # Associate with the 'production' environment
url: https://khadervali

Written by

Khader Vali

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

Share this article

Related Articles

GitHub Actions: CI/CD from Basics to Matrix Builds

Jun 20, 2026 · 17 min read

Platform Engineering: Building Internal Developer Platforms

Jul 25, 2026 · 16 min read

Observability with Prometheus, Grafana & OpenTelemetry

Jul 03, 2026 · 15 min read