•10 min read

How I Set Up CI/CD with GitHub Actions

How I Set Up CI/CD with GitHub Actions

I was late to CI/CD. For years I'd run tests locally, cross my fingers, and push. It worked fine until the day I pushed a commit that broke the build and spent an hour figuring out which dependency change caused it.

GitHub Actions is free for public repositories and the cheapest option I've found for private ones. This is the setup I've landed on after running it across a dozen different projects - from Next.js apps to Python APIs to Go services.

Audio Briefing
0:00 / 0:00

Why GitHub Actions Over the Alternatives?

Before picking a CI tool: GitHub Actions runs directly on your repository, no third-party service to connect. Jenkins requires a server. CircleCI has better free tiers for some use cases. Travis CI went paid-only. GitLab CI is excellent if you're already on GitLab.

For GitHub-hosted projects, Actions wins on convenience: no webhook setup, no token management, triggers defined next to your code.


Advertisement

Your First Workflow

GitHub Actions workflows live in .github/workflows/ as YAML files. Create .github/workflows/ci.yml:

name: CI

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - run: npm run build

Push this and your next commit triggers the workflow. Every PR gets a green checkmark or a red X, and you'll never merge broken code again.

npm ci vs npm install

Use npm ci in CI, not npm install. npm ci is stricter: it installs exactly what's in package-lock.json, fails if there's a mismatch, and never updates the lockfile. This means your CI environment matches what you tested locally.


Caching Makes It Fast

The cache: 'npm' line above caches node_modules between runs based on the hash of your package-lock.json. Without it, every workflow run reinstalls all dependencies from scratch - 60–120 seconds per run depending on your dependency count.

With caching, cache hits reduce that to 5–10 seconds.

For other ecosystems:

# Python (pip)
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

# Python (uv)
- uses: astral-sh/setup-uv@v3
  with:
    enable-cache: true

# Go
- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true

# Rust
- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target/
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

The cache is keyed on your lockfile hash. When dependencies change, the cache misses and rebuilds. When they don't, the cache hits and CI is fast.


Testing in Parallel

For larger test suites, split tests across multiple runners:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4

This runs 4 jobs simultaneously, each handling a quarter of your tests. Total test time drops from 4× to ~1×.

Playwright supports this natively with --shard. For Jest, use --testPathPattern or jest-runner-groups. For pytest, use pytest-split.


Advertisement

Deploying Automatically

Separate CI (tests) from CD (deploy). They have different failure semantics - a test failure should block a PR, but a deploy failure shouldn't block other PRs.

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

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
      - name: Deploy to Vercel
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
        run: npx vercel --prod --token $VERCEL_TOKEN
Use Secrets, Not Hardcoded Tokens

Never put API keys or deployment tokens directly in your YAML files. Store them in GitHub → Settings → Secrets and variables → Actions, then reference them as ${{ secrets.MY_TOKEN }}. Secrets are encrypted at rest, masked in logs, and never exposed to fork PRs.

Deploying Only on Successful Tests

Chain CI and CD with the needs keyword:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: npx vercel --prod --token ${{ secrets.VERCEL_TOKEN }}

The deploy job only runs if test succeeds and if we're on the main branch.


Environment Variables and Secrets

Three tiers for handling configuration in GitHub Actions:

1. Hardcoded in YAML - Only for non-sensitive, universal values:

env:
  NODE_ENV: production
  PORT: 3000

2. Repository secrets - Sensitive values (API keys, tokens):

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

3. Environment secrets - Different values per deployment environment (staging vs production):

jobs:
  deploy:
    environment: production  # uses the "production" environment's secrets
    steps:
      - run: deploy.sh
        env:
          API_URL: ${{ secrets.API_URL }}  # production-specific value

Environment secrets require approval gates: configure required reviewers in Settings → Environments → protection rules. Useful for preventing accidental production deploys.


Things I Learned the Hard Way

Run CI on every PR, not just main. Catching a test failure on a PR branch is much cheaper than catching it after merge. Add pull_request: branches: [main] to every workflow.

Keep CI and CD separate. Your CI workflow runs every push and should complete in under 5 minutes. Your CD workflow deploys and can take longer. Mixing them means a slow deploy blocks your PR feedback loop.

Matrix builds multiply your bill. Testing across multiple Node versions or OSes with a matrix strategy multiplies your runner time. I use it only for libraries where cross-version compatibility actually matters. For apps, one target environment is enough.

# Only do this for libraries, not apps
strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest, macos-latest]

That's 9 runners per push. At 2 minutes each, that's 18 minutes of compute.

Watch your minutes. GitHub Actions gives 2,000 free minutes/month for private repos on the free plan. One project with matrix builds and 10 pushes/day can burn through that in a week.

Fail fast with fail-fast: true (the default in matrix builds). If one matrix job fails, GitHub cancels the others. Saves minutes when you have a fundamental error.

Use actions/cache explicitly for Docker layers:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
  with:
    context: .
    cache-from: type=gha
    cache-to: type=gha,mode=max

Without this, Docker rebuilds your entire image from scratch on every run.


A Complete Production-Ready Workflow

Here's the full workflow I use for a Next.js project:

name: CI

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

concurrency:
  group: ${{ github.workflow }}-${{ 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'
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  build:
    runs-on: ubuntu-latest
    needs: [lint, 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: .next/
          retention-days: 1

Key features:

  • concurrency cancels in-progress runs when a new push arrives - prevents queue buildup during active development
  • lint and test run in parallel, build waits for both
  • Coverage reports go to Codecov on every push
  • Build artifacts upload for 1 day (useful for deploy jobs to consume without rebuilding)

Debugging Failing Workflows

When a workflow fails and the log isn't clear:

Enable debug logging by setting a repository secret ACTIONS_STEP_DEBUG=true. This dumps verbose output from every step.

SSH into the runner using tmate:

- uses: mxschmitt/action-tmate@v3
  if: ${{ failure() }}

This pauses the workflow and gives you an SSH connection to the exact runner environment when a step fails. Invaluable for debugging environment-specific issues.

Print environment details:

- run: node --version && npm --version && env | sort

You Might Also Like

Test Your Knowledge

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement