Skip to main content

GitHub Actions: CI/CD Pipelines That Don't Suck

March 17, 2026

Most CI/CD pipelines I encounter in the wild are terrible. They take 20 minutes to run, fail on flaky tests, cache nothing, and cost a fortune in compute minutes. It does not have to be this way.

GitHub Actions is a powerful platform, but power without discipline leads to slow, expensive pipelines. Let me walk you through building a CI/CD setup that is fast, reliable, and cheap.

Why Most Pipelines Are Bad

The usual problems:

  • No caching. Every run installs dependencies from scratch.
  • Sequential steps that could be parallel. Linting waits for tests that wait for type checking.
  • Running everything on every push. Full E2E suites on a README change.
  • No path filtering. Backend tests run when you change frontend code.
  • Oversized runners. Using ubuntu-latest for everything when a smaller runner would do.

Let us fix all of these.

GitHub Actions Fundamentals

If you are new to Actions, here is the mental model. A workflow is a YAML file in .github/workflows/. It contains jobs, which run on runners (virtual machines). Jobs contain steps, which are either shell commands or reusable actions.

Jobs run in parallel by default. Steps within a job run sequentially. This distinction matters for pipeline design.

Building a Real Pipeline

Here is a production-ready pipeline for a Next.js application:

name: CI

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

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm lint

      - name: Type check
        run: pnpm tsc --noEmit

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

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Run tests
        run: pnpm test -- --coverage

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  build:
    needs: [lint-and-typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build

      - name: Upload build
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/

  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: [build]
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Vercel
        run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}

Key design decisions here:

  1. concurrency with cancel-in-progress — If you push again before the pipeline finishes, the old run is cancelled. No wasted minutes.
  2. Parallel jobs — Lint/typecheck and tests run simultaneously. Build only starts after both pass.
  3. --frozen-lockfile — Ensures CI uses the exact dependencies from the lockfile. No surprise version bumps.
  4. Conditional deploy — Only deploys on pushes to main, not on pull requests.

Caching Strategies

The built-in cache option on setup-node handles node_modules. But for larger projects, you need more aggressive caching.

Cache the Next.js build cache:

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

Cache Playwright browsers for E2E tests:

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

- name: Install Playwright (if not cached)
  run: pnpm exec playwright install --with-deps chromium

Caching alone can cut pipeline time by 40-60%.

Matrix Builds

Need to test across multiple Node versions or operating systems? Matrix builds run them in parallel:

test:
  strategy:
    matrix:
      node-version: [20, 22]
      os: [ubuntu-latest, windows-latest]
    fail-fast: false
  runs-on: ${{ matrix.os }}
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

Set fail-fast: false so all matrix combinations run even if one fails. You want to see the full picture.

Secrets Management

Never hardcode secrets. Use GitHub's encrypted secrets, scoped appropriately:

  • Repository secrets for project-specific values (API keys, deploy tokens)
  • Environment secrets for deployment-specific values (production vs staging URLs)
  • Organization secrets for shared values across repos
env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  API_KEY: ${{ secrets.API_KEY }}

For secrets that multiple steps need, set them at the job level. For secrets only one step needs, set them at the step level. Principle of least privilege applies here too.

Use OIDC for cloud providers instead of long-lived credentials:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-arn: arn:aws:iam::123456789:role/github-actions
      aws-region: us-east-1

No AWS access keys stored as secrets. The token is generated per-run and expires immediately.

Reusable Workflows

Stop copying YAML between repos. Create reusable workflows:

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

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      DEPLOY_TOKEN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Call it from other workflows:

deploy-staging:
  uses: ./.github/workflows/reusable-deploy.yml
  with:
    environment: staging
  secrets:
    DEPLOY_TOKEN: ${{ secrets.STAGING_TOKEN }}

Cost Optimization

GitHub Actions bills by the minute. Here is how to keep costs down:

  1. Use path filters. Do not run backend tests when only docs changed:
on:
  push:
    paths:
      - 'src/**'
      - 'package.json'
      - 'pnpm-lock.yaml'
  1. Cancel redundant runs. The concurrency group shown earlier handles this.

  2. Use smaller runners where possible. Not every job needs a full ubuntu-latest. GitHub now offers smaller runner options.

  3. Timeout your jobs. Prevent runaway processes from burning minutes:

jobs:
  test:
    timeout-minutes: 10
  1. Skip CI when appropriate. Add [skip ci] to commit messages for documentation-only changes.

The Pipeline Checklist

Before calling your pipeline done, verify:

  • Runs in under 5 minutes for the common case (push to feature branch)
  • Caches dependencies and build artifacts
  • Runs lint, typecheck, and tests in parallel
  • Only deploys from the main branch
  • Cancels redundant runs
  • Has path filters to skip irrelevant jobs
  • Uses OIDC instead of long-lived cloud credentials
  • Has timeouts on every job
  • Uses --frozen-lockfile for reproducible installs

A well-built pipeline is invisible. It catches problems early, deploys reliably, and never makes you wait. Invest the time to get it right — every developer on your team benefits from every minute you shave off the build.

Recommended Posts