I’ve written more GitHub Actions YAML than I’d like to admit. Over the past three years, I’ve gone from “why is my build failing with a cryptic error” to running CI pipelines that handle deployments to 4 environments, run 2,000+ tests in parallel, and finish in under 4 minutes.

Here are the patterns, tricks, and hard-won lessons that got me there.

The Fundamentals That Most People Get Wrong

1. Trigger Configuration

Most workflows I review have overly broad triggers. This wastes compute and slows down feedback:

# ❌ Bad: Runs on EVERY push to EVERY branch
on: push

# ❌ Also bad: Runs on pushes AND PRs (double builds)
on: [push, pull_request]

# ✅ Good: Runs on PRs to main and pushes to main only
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

2. Path Filtering

Don’t run your full test suite when someone updates a README:

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/**'
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.vscode/**'

Pro Tip: Use paths-ignore for documentation repos and paths for monorepos. In a monorepo, you want to explicitly list what SHOULD trigger builds rather than what shouldn’t.

Caching: The Single Biggest Speed Win

Without caching, every workflow run downloads all dependencies from scratch. With proper caching, you skip this entirely 90% of the time.

Node.js Caching

- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'npm'  # Built-in caching!

# Or with more control:
- name: Cache node_modules
  uses: actions/cache@v4
  id: cache-deps
  with:
    path: node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: npm ci

Multi-Layer Caching Strategy

# Cache multiple things with different invalidation strategies
- name: Cache npm packages
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}

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

- name: Cache Playwright browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ hashFiles('package-lock.json') }}

Cache Impact on Build Times

Step Without Cache With Cache Savings
npm install 45s 2s 96%
Next.js build 90s 15s 83%
Playwright install 30s 0s 100%
Docker layers 120s 10s 92%
Total 285s 27s 91%

Matrix Builds: Parallel Testing

Matrix builds let you test across multiple configurations simultaneously:

jobs:
  test:
    strategy:
      fail-fast: false  # Don't cancel other matrix jobs if one fails
      matrix:
        node-version: [20, 22]
        os: [ubuntu-latest, macos-latest]
        shard: [1, 2, 3, 4]  # Split tests into 4 parallel groups
    
    runs-on: ${{ matrix.os }}
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4

Intelligent Test Splitting

# Split tests by timing data for even distribution
- name: Run tests with sharding
  run: |
    npx vitest --reporter=json --outputFile=results.json \
      --shard=${{ matrix.shard }}/${{ strategy.job-total }}

Pro Tip: Use fail-fast: false in your matrix strategy during development. It’s frustrating to have all matrix jobs cancelled because of one flaky test on a specific OS. Only enable fail-fast: true for final validation before merge.

Reusable Workflows: DRY Your YAML

If you’re copying the same workflow across 10 repos, you’re doing it wrong:

# .github/workflows/reusable-test.yml (in a shared repo)
name: Reusable Test Workflow
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '22'
      working-directory:
        type: string
        default: '.'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
      - run: npm ci
      - run: npm test
# In consuming repos:
name: CI
on: [pull_request]

jobs:
  test:
    uses: our-org/shared-workflows/.github/workflows/reusable-test.yml@main
    with:
      node-version: '22'
    secrets: inherit

Composite Actions: Reusable Steps

For smaller reusable pieces, composite actions are cleaner than full reusable workflows:

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install deps, setup env, cache'

inputs:
  node-version:
    description: 'Node.js version'
    default: '22'

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    
    - name: Install dependencies
      shell: bash
      run: npm ci
    
    - name: Setup environment
      shell: bash
      run: cp .env.ci .env
# Usage in any workflow:
steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-project
    with:
      node-version: '22'
  - run: npm test

Security Hardening

Pin Action Versions to SHA

# ❌ Risky: Tag can be moved (supply chain attack vector)
- uses: actions/checkout@v4

# ✅ Secure: Pinned to specific SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Minimal Permissions

# Set minimal permissions at workflow level
permissions:
  contents: read
  pull-requests: write  # Only if needed

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read  # Override at job level for even tighter control

Secret Handling

# ❌ Bad: Secrets can leak in logs
- run: echo "Token is ${{ secrets.API_TOKEN }}"

# ✅ Good: Mask secrets and use environment variables
- name: Deploy
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
  run: ./deploy.sh  # Script uses $API_TOKEN

# ✅ Best: Use OIDC for cloud providers (no stored secrets)
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-deploy
    aws-region: us-east-1

Pro Tip: Use OIDC authentication for AWS, GCP, and Azure instead of storing long-lived credentials as secrets. This eliminates secret rotation concerns and follows the principle of least privilege with temporary credentials.

Concurrency Control

Prevent redundant builds when you push multiple commits:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # Cancel previous runs for same branch

For deployments, you want different behavior:

# For deployments: queue instead of cancel
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false  # Don't cancel in-progress deployments!

Monorepo Patterns

For monorepos, use path filters and dynamic matrices:

# Detect which packages changed
- name: Detect changes
  id: changes
  uses: dorny/paths-filter@v3
  with:
    filters: |
      api:
        - 'packages/api/**'
      web:
        - 'packages/web/**'
      shared:
        - 'packages/shared/**'

# Only test what changed
- name: Test API
  if: steps.changes.outputs.api == 'true' || steps.changes.outputs.shared == 'true'
  run: npm test --workspace=packages/api

- name: Test Web
  if: steps.changes.outputs.web == 'true' || steps.changes.outputs.shared == 'true'
  run: npm test --workspace=packages/web

For teams adopting monorepo strategies, this pattern prevents wasting CI minutes on unchanged packages.

Docker Build Optimization in CI

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

- name: Login to Container Registry
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

The cache-from: type=gha uses GitHub’s built-in cache for Docker layers — massive speedup for Docker builds.

Real-World Complete Workflow

Here’s the actual CI/CD workflow I use for production services:

name: CI/CD

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

permissions:
  contents: read
  packages: write
  pull-requests: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3]
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        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: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test
      - run: npm test -- --shard=${{ matrix.shard }}/3
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test

  deploy:
    needs: [lint-and-typecheck, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-1
      - run: ./scripts/deploy.sh

Common Mistakes

1. Not Using needs for Job Dependencies

# ❌ Deploy might start before tests finish
jobs:
  test: ...
  deploy: ...

# ✅ Deploy waits for test to pass
jobs:
  test: ...
  deploy:
    needs: [test]

2. Forgetting fail-fast in Matrices

By default, if one matrix job fails, all others are cancelled. This is rarely what you want during development.

3. Not Handling Flaky Tests

# Retry flaky tests (use sparingly!)
- name: Run tests with retry
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 10
    max_attempts: 3
    command: npm test

4. Storing Artifacts Without Expiration

# ✅ Always set retention
- uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: coverage/
    retention-days: 7  # Don't keep forever!

5. Not Using Environments for Deployment Protection

jobs:
  deploy:
    environment: production  # Requires manual approval + env secrets

FAQ

How do I debug a failing GitHub Action?

Three approaches: (1) Add - run: env to print environment variables, (2) Use actions/upload-artifact to save logs/screenshots, (3) Use mxschmitt/action-tmate to SSH into the runner for interactive debugging. For the SSH approach, add it as a step with if: failure() so it only activates when something goes wrong.

How do I speed up Docker builds in GitHub Actions?

Use Docker Buildx with GitHub Actions cache (cache-from: type=gha). This stores layer cache in GitHub’s cache storage and reuses it across runs. Combined with multi-stage builds and proper layer ordering in your Dockerfile, you can get Docker builds from 5+ minutes to under 30 seconds.

Can I run GitHub Actions locally?

Yes, use act. It runs workflows locally using Docker. Not 100% compatible (some GitHub-specific features don’t work), but great for iterating on workflow syntax without pushing commits. Install with brew install act, then run act in your repo root.

How do I handle secrets for pull requests from forks?

Secrets aren’t available to workflows triggered by PRs from forks (security feature). Use pull_request_target trigger (runs in context of base repo) for workflows that need secrets, but be careful — this has security implications. Always validate the PR diff before running anything privileged.

What’s the best way to deploy to multiple environments?

Use GitHub Environments with environment-specific secrets and protection rules. Chain jobs with needs and use environment: staging → run smoke tests → environment: production (with required reviewers). This gives you a visual deployment pipeline in the Actions UI.