Skip to main content
Back to Articles
Server & DevOpsJuly 5, 20258 min read

Building CI/CD Pipelines with GitHub Actions

Design production-grade CI/CD pipelines using GitHub Actions with matrix builds, environment protection rules, and automated rollback strategies.

Beyond Basic Workflows

Most teams start with a simple GitHub Actions workflow that runs tests on push. But production-grade CI/CD requires much more: matrix builds across runtime versions, environment protection rules, secrets management, caching strategies, artifact handling, and automated rollback triggers.

This guide covers advanced patterns that we implement when setting up DevOps pipelines for engineering teams shipping to production multiple times per day.

Pipeline Architecture

A robust CI/CD pipeline has distinct stages with clear gates between them:

Build Stage: Compile, lint, and run unit tests. Fail fast on code quality issues.

Test Stage: Integration tests, E2E tests, and security scans run in parallel. Each test suite runs in its own job for isolation and parallelism.

Deploy Stage: Progressive deployment through staging, canary, and production environments with manual approval gates.

Advanced Workflow Configuration

name: Production Pipeline

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

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

jobs:
  build:
    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 run build

      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 20
        with:
          name: build-output
          path: dist/
          retention-days: 5

  test:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        test-suite: [unit, integration, e2e]
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_PASSWORD: test_pass
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run test:${{ matrix.test-suite }}
        env:
          DATABASE_URL: postgres://postgres:test_pass@localhost:5432/test_db

Key Design Decisions

The concurrency block cancels in-progress runs when new commits arrive on the same branch — essential for busy repositories where developers push frequently.

Setting fail-fast: false on the test matrix ensures all test suites complete even if one fails. This gives developers complete failure information in a single run rather than requiring multiple push-and-wait cycles.

Environment Protection Rules

GitHub Environments provide approval gates and deployment tracking:

  deploy-staging:
    needs: [test]
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to staging
        run: |
          aws s3 sync dist/ s3://staging-bucket/ --delete
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CF_DIST_ID }} \
            --paths "/*"

  deploy-production:
    needs: [deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to production
        run: |
          aws s3 sync dist/ s3://production-bucket/ --delete
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.PROD_CF_DIST_ID }} \
            --paths "/*"

Configure the production environment in GitHub to require manual approval from team leads. The workflow pauses at the deploy-production job until an authorized reviewer approves.

Caching for Speed

Aggressive caching cuts build times by 60-80 percent:

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
            .next/cache
          key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            deps-${{ runner.os }}-

Automated Rollback

Implement post-deployment health checks that trigger automatic rollback:

      - name: Smoke test
        id: smoke
        continue-on-error: true
        run: |
          for i in {1..5}; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://example.com/healthz)
            if [ "$STATUS" != "200" ]; then
              echo "Health check failed with status $STATUS"
              exit 1
            fi
            sleep 5
          done

      - name: Rollback on failure
        if: steps.smoke.outcome == 'failure'
        run: |
          aws s3 sync s3://production-backup/ s3://production-bucket/ --delete
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.PROD_CF_DIST_ID }} \
            --paths "/*"
          echo "::error::Deployment rolled back due to failed health checks"
          exit 1

Security Scanning

Add SAST and dependency scanning as required checks:

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

Best Practices

  • Keep workflows DRY using reusable workflows and composite actions
  • Pin action versions to specific SHA hashes, not tags, for supply chain security
  • Use OIDC for AWS authentication instead of long-lived access keys
  • Store deployment metadata (commit SHA, timestamp, actor) for audit trails
  • Set up monitoring and alerting to catch issues that automated tests miss

Implementing these patterns transforms CI/CD from a simple automation layer into a reliable software delivery system.

Need help with this?

Our team handles this kind of work daily. Let us take care of your infrastructure.