GitHub Actions has become the de facto CI/CD platform for modern development teams. This guide takes you from basic workflows to production-grade pipelines with caching, matrix builds, and secure deployments.

Understanding GitHub Actions

GitHub Actions uses a hierarchy: Workflows contain Jobs, which contain Steps. Workflows are triggered by Events.

# .github/workflows/ci.yml
name: CI Pipeline              # Workflow name

on:                            # Events that trigger the workflow
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:                          # Jobs run in parallel by default
  test:                        # Job ID
    runs-on: ubuntu-latest     # Runner environment
    steps:                     # Sequential steps
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test

A Complete CI Pipeline

Here’s a production-ready CI pipeline with caching, linting, testing, and security scanning:

name: CI

on:
  push:
    branches: [main]
  pull_request:

env:
  NODE_VERSION: '20'

jobs:
  # ─────────────────────────────────────────────────────────
  # Lint and type-check
  # ─────────────────────────────────────────────────────────
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run TypeScript type check
        run: npm run type-check

  # ─────────────────────────────────────────────────────────
  # Unit tests with coverage
  # ─────────────────────────────────────────────────────────
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          fail_ci_if_error: true

  # ─────────────────────────────────────────────────────────
  # Security scanning
  # ─────────────────────────────────────────────────────────
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Run npm audit
        run: npm audit --audit-level=high

  # ─────────────────────────────────────────────────────────
  # Build
  # ─────────────────────────────────────────────────────────
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test, security]  # Wait for these jobs
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/
          retention-days: 7

Matrix Builds

Test across multiple versions and platforms simultaneously:

jobs:
  test:
    name: Test (${{ matrix.os }} / Node ${{ matrix.node }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false  # Don't cancel other jobs if one fails
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: windows-latest
            node: 18  # Skip Node 18 on Windows

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Caching Strategies

Caching dramatically speeds up workflows:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Cache node_modules
      - name: Cache node_modules
        uses: actions/cache@v4
        id: cache-node-modules
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}

      # Only install if cache miss
      - name: Install dependencies
        if: steps.cache-node-modules.outputs.cache-hit != 'true'
        run: npm ci

      # Cache Next.js build
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: |
            .next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-

      - name: Build
        run: npm run build

Reusable Workflows

Create workflows that can be called from other workflows:

# .github/workflows/reusable-deploy.yml
name: Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      version:
        required: true
        type: string
    secrets:
      AWS_ACCESS_KEY_ID:
        required: true
      AWS_SECRET_ACCESS_KEY:
        required: true

jobs:
  deploy:
    name: Deploy to ${{ inputs.environment }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}  # GitHub environment
    steps:
      - uses: actions/checkout@v4

      - 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: us-east-1

      - name: Deploy
        run: |
          echo "Deploying version ${{ inputs.version }} to ${{ inputs.environment }}"
          # Your deployment commands here

Call it from another workflow:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy-staging:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      version: ${{ github.ref_name }}
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      version: ${{ github.ref_name }}
    secrets:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Composite Actions

Create custom actions that bundle multiple steps:

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Setup Node.js with caching and install dependencies'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      shell: bash
      run: npm ci

    - name: Cache build outputs
      uses: actions/cache@v4
      with:
        path: |
          .next/cache
          node_modules/.cache
        key: ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }}

Use it in workflows:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
        with:
          node-version: '20'
      - run: npm run build

Secrets and Environments

Using Secrets

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: |
          # Secrets are masked in logs automatically
          ./deploy.sh

Environment Protection Rules

Configure environments in GitHub Settings → Environments:

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - name: Deploy
        run: ./deploy.sh

Environment features:

  • Required reviewers: Manual approval before deployment
  • Wait timer: Delay before deployment starts
  • Branch restrictions: Only allow deployments from specific branches
  • Environment secrets: Secrets scoped to an environment

Docker Build and Push

Build and push Docker images with caching:

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: myorg/myapp
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Deployment Strategies

Rolling Deployment to Kubernetes

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Update image
        run: |
          kubectl set image deployment/myapp \
            myapp=myorg/myapp:${{ github.sha }} \
            --record

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/myapp --timeout=5m

Blue-Green Deployment

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to blue environment
        run: |
          kubectl apply -f k8s/deployment-blue.yaml
          kubectl wait --for=condition=available deployment/myapp-blue

      - name: Run smoke tests
        run: |
          ./scripts/smoke-test.sh http://myapp-blue.internal

      - name: Switch traffic to blue
        run: |
          kubectl patch service myapp -p '{"spec":{"selector":{"version":"blue"}}}'

      - name: Scale down green
        run: |
          kubectl scale deployment/myapp-green --replicas=0

Workflow Optimization Tips

Conditional Jobs

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

Skip CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.github/ISSUE_TEMPLATE/**'

Timeout and Concurrency

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15  # Kill job if it takes too long

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # Cancel old runs when new commit pushed

Debugging Workflows

Enable Debug Logging

Set these secrets in your repository:

  • ACTIONS_RUNNER_DEBUG: true
  • ACTIONS_STEP_DEBUG: true

SSH Into Runner

- name: Setup tmate session
  if: ${{ failure() }}  # Only on failure
  uses: mxschmitt/action-tmate@v3
  with:
    limit-access-to-actor: true

Key Takeaways

  1. Use caching aggressively — it saves time and money
  2. Create reusable workflows for common patterns
  3. Use environments for deployment protection
  4. Matrix builds catch cross-platform issues early
  5. Fail fast — don’t wait for slow tests if linting fails
  6. Set timeouts — runaway jobs are expensive

“The goal of CI is not to have green builds. It’s to have the confidence to deploy at any time.”