GitHub Actions is the most popular CI/CD platform for open source and increasingly for enterprise. But most teams use it like a simple script runner — one workflow, no caching, no parallelism, 20-minute builds. This guide shows you the patterns that make CI/CD fast, reliable, and maintainable.

Optimized CI/CD Pipeline Architecture
Push
Trigger
concurrency group
Lint
Fast Fail
cached deps
Test
Matrix
parallel versions
Build
Cache Hit
incremental
Deploy
OIDC Auth
zero secrets

Workflow Fundamentals Done Right

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

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

# Cancel in-progress runs on the same branch
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'       # Built-in npm cache!
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: lint              # Only test if lint passes
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

Matrix Builds: Test Across Versions

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
      fail-fast: false    # Do not cancel other matrix jobs on failure
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

# This creates 6 parallel jobs:
# node 18 + ubuntu, node 18 + windows
# node 20 + ubuntu, node 20 + windows
# node 22 + ubuntu, node 22 + windows

Aggressive Caching

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

      # Cache node_modules (faster than npm ci every time)
      - uses: actions/cache@v4
        id: npm-cache
        with:
          path: node_modules
          key: node-modules-${{ hashFiles('package-lock.json') }}

      - if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm ci

      # Cache Next.js / Angular build cache
      - uses: actions/cache@v4
        with:
          path: .next/cache    # or .angular/cache
          key: build-cache-${{ hashFiles('src/**') }}
          restore-keys: build-cache-

      - run: npm run build

# Cache hit rate matters:
# No cache:     npm ci takes 45 seconds every run
# With cache:   npm ci skipped, build uses incremental cache
# Total savings: 60-80% of build time

Reusable Workflows

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

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      app-name:
        required: true
        type: string
    secrets:
      AWS_ACCESS_KEY_ID:
        required: true
      AWS_SECRET_ACCESS_KEY:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - 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
      - run: aws s3 sync dist/ s3://${{ inputs.app-name }}-${{ inputs.environment }}/

# Caller workflow:
# .github/workflows/deploy-staging.yml
name: Deploy Staging
on:
  push:
    branches: [main]
jobs:
  build:
    uses: ./.github/workflows/ci.yml
  deploy:
    needs: build
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      app-name: myapp
    secrets: inherit

Secrets Management

# Secrets are encrypted and masked in logs
# Access via: ${{ secrets.SECRET_NAME }}

# Best practices:
# 1. Use environment-scoped secrets (not repo-level) for production
# 2. Use OIDC for cloud providers (no long-lived credentials)
# 3. Rotate secrets regularly
# 4. Never echo secrets in run commands

# OIDC authentication (no AWS keys needed!):
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions
          aws-region: us-east-1
          # No access keys! Uses temporary OIDC tokens

Monorepo Strategies

# Only run jobs when relevant files change
  backend:
    runs-on: ubuntu-latest
    if: contains(github.event.head_commit.modified, 'backend/') || github.event_name == 'workflow_dispatch'
    steps:
      - uses: actions/checkout@v4
      - run: cd backend && npm test

# Better approach: path filters with dorny/paths-filter
  changes:
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      frontend: ${{ steps.filter.outputs.frontend }}
      infra: ${{ steps.filter.outputs.infra }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'backend/**'
            frontend:
              - 'frontend/**'
            infra:
              - 'terraform/**'

  test-backend:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd backend && npm test

  test-frontend:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd frontend && npm test

Service Containers for Integration Tests

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

Common Mistakes

  • No concurrency control: Multiple runs on the same branch waste resources. Use concurrency to cancel outdated runs.
  • Installing dependencies in every job: Cache node_modules/pip packages. A cache hit saves 30-60 seconds per job.
  • Running all tests on every change: In monorepos, use path filters to only test what changed.
  • Long-lived cloud credentials: Use OIDC instead of static access keys. Temporary tokens cannot be leaked.
  • No fail-fast: false in matrix: One failing version cancels all other jobs by default. Set fail-fast: false to see all results.
  • Not using artifacts: Build once, deploy many times. Upload build output as an artifact instead of rebuilding for each environment.

Key Takeaways

  • Use concurrency groups to cancel outdated CI runs and save compute
  • Cache aggressively: node_modules, build caches, Docker layers — cache everything that does not change often
  • Matrix builds test across versions in parallel — catch compatibility issues early
  • Reusable workflows eliminate duplication — define once, call from multiple workflows
  • Use OIDC for cloud authentication — no static credentials to rotate or leak
  • Path filters in monorepos save massive CI time — only test what changed
  • Build once, deploy many: upload artifacts from build, download in deploy jobs

Fast CI/CD is a competitive advantage. A 3-minute pipeline means developers merge multiple times per day. A 20-minute pipeline means they batch changes and merge once. The patterns in this guide — caching, parallelism, path filters, reusable workflows — can cut your build time by 80% with a few hours of investment.