GitHub Actions has become the default CI/CD for many teams. Here are patterns I’ve seen work well in production, and a few anti-patterns to avoid.

The Foundation: A Reusable Test Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
name: Test

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

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      - run: npm test

Key details:

  • actions/checkout@v4 — always pin to major version
  • cache: 'npm' — built-in caching, no separate action needed
  • npm ci — faster and stricter than npm install

Matrix Builds Done Right

Test across multiple versions without copy-paste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [18, 20, 22]
        os: [ubuntu-latest, macos-latest]
      fail-fast: false
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

fail-fast: false lets all combinations finish — useful for knowing the full picture.

Conditional Deployment

Deploy only on main, only after tests pass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
jobs:
  test:
    # ... test job

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: ./scripts/deploy.sh

The needs keyword creates the dependency. The if controls when it runs.

Caching Dependencies Properly

For complex caching beyond the built-in:

1
2
3
4
5
6
7
- name: Cache node modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

The restore-keys fallback means a close-enough cache beats a cold start.

Docker Build and Push

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Login to 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 != 'pull_request' }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from/cache-to with type=gha uses GitHub’s cache for Docker layers.

Reusable Workflows

Stop duplicating workflows across repos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# .github/workflows/reusable-test.yml
name: Reusable Test

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci && npm test

Call it from another workflow:

1
2
3
4
5
jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '22'

Or from another repo:

1
2
3
jobs:
  test:
    uses: myorg/shared-workflows/.github/workflows/test.yml@main

Environment Protection

For production deployments with approval:

1
2
3
4
5
6
7
8
jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment: production
    
    steps:
      - name: Deploy to production
        run: ./deploy.sh prod

Configure the production environment in repo settings with required reviewers.

Secrets Management Pattern

Pass secrets explicitly, not through env inheritance:

1
2
3
4
5
- name: Deploy
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: aws s3 sync ./dist s3://my-bucket

For OIDC-based auth (no static secrets):

1
2
3
4
5
- name: Configure AWS
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions
    aws-region: us-east-1

Anti-Patterns to Avoid

1. Running everything on every push

1
2
3
4
5
6
7
8
9
# Bad: runs on every file change
on: push

# Better: filter by path
on:
  push:
    paths:
      - 'src/**'
      - 'package.json'

2. Not canceling redundant runs

1
2
3
4
# Add this to cancel previous runs on same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

3. Hardcoding versions everywhere

Use organization variables or a .tool-versions file:

1
2
3
- uses: actions/setup-node@v4
  with:
    node-version-file: '.nvmrc'

Debugging Failed Workflows

When things go wrong:

1
2
3
4
5
6
- name: Debug info
  run: |
    echo "Event: ${{ github.event_name }}"
    echo "Ref: ${{ github.ref }}"
    echo "SHA: ${{ github.sha }}"
    env | sort

Or enable debug logging by setting ACTIONS_STEP_DEBUG secret to true.

The 80/20 Rule

Most projects need just three workflows:

  1. Test on PR — catch issues before merge
  2. Build and deploy on main — ship automatically
  3. Scheduled security scan — catch vulnerabilities

Everything else is optimization. Start simple, add complexity only when needed.