CI/CD

Building a CI/CD Pipeline with GitHub Actions

Step-by-step guide to building a robust continuous delivery pipeline with GitHub Actions, including testing, security scanning, and automated deployments.

GitHub Actions has become my go-to for CI/CD. It's tightly integrated with your code, the marketplace has actions for everything, and the YAML syntax is straightforward once you know the patterns.

A Complete Pipeline

Here's the pipeline structure I use for most projects:

name: CI/CD Pipeline

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

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

Stage 1: Test

Run your test suite on every push and PR:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage

      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 22
        with:
          name: coverage-report
          path: coverage/

The matrix strategy runs tests across multiple Node versions in parallel. This catches compatibility issues early.

Stage 2: Security Scanning

Security should be automated, not an afterthought:

  security:
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "fs"
          format: "sarif"
          output: "trivy-results.sarif"
          severity: "CRITICAL,HIGH"

      - name: Audit dependencies
        run: npm audit --audit-level=high

Trivy scans your filesystem for known vulnerabilities. Pair it with npm audit for comprehensive coverage.

Stage 3: Build and Push

Build your Docker image and push to a registry:

  build:
    runs-on: ubuntu-latest
    needs: [test, security]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from and cache-to lines enable GitHub Actions' built-in layer caching — this significantly speeds up subsequent builds.

Stage 4: Deploy

Deploy to your Kubernetes cluster:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment: production

    steps:
      - uses: actions/checkout@v4

      - uses: azure/setup-kubectl@v3

      - uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBE_CONFIG }}

      - name: Update image tag
        run: |
          kubectl set image deployment/my-app \
            app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            --namespace=production

      - name: Verify rollout
        run: |
          kubectl rollout status deployment/my-app \
            --namespace=production \
            --timeout=300s

The environment: production line enables GitHub's environment protection rules — you can require manual approvals before production deploys.

Reusable Workflows

Once your pipeline matures, extract common patterns into reusable workflows:

# .github/workflows/reusable-deploy.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
    secrets:
      KUBE_CONFIG:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: azure/setup-kubectl@v3
      - uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBE_CONFIG }}
      - run: |
          kubectl set image deployment/my-app \
            app=${{ inputs.image-tag }} \
            --namespace=${{ inputs.environment }}

Then call it from any workflow:

deploy-staging:
  uses: ./.github/workflows/reusable-deploy.yml
  with:
    environment: staging
    image-tag: ghcr.io/my-org/my-app:abc123
  secrets:
    KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}

Tips I've Learned

Cache aggressively. Use actions/cache or built-in caching for package managers. Build times drop dramatically.

Use concurrency to prevent duplicate runs:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This cancels in-flight runs when you push new commits to the same branch.

Keep secrets minimal. Use OIDC federation instead of long-lived credentials where possible. AWS, GCP, and Azure all support this natively.

Monitor your pipeline. Track build times, failure rates, and flaky tests. A slow or unreliable pipeline erodes developer trust.

Wrapping Up

A well-built CI/CD pipeline is one of the highest-leverage investments you can make. It catches bugs before they reach production, enforces security policies automatically, and gives your team confidence to ship fast.

Start simple — test, build, deploy — and layer on security scanning, caching, and reusable workflows as your needs grow.