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-ignorefor documentation repos andpathsfor 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: falsein your matrix strategy during development. It’s frustrating to have all matrix jobs cancelled because of one flaky test on a specific OS. Only enablefail-fast: truefor 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.
