The Practical Developer

CI/CD From Zero to Production in 30 Minutes With GitHub Actions

A no-fluff guide to shipping a real CI/CD pipeline that lints, tests, builds, and deploys automatically, without the enterprise boilerplate.

Terminal window showing a successful pipeline run

A CI/CD pipeline doesn’t need to be complex. Here’s a real workflow that runs on every push, catches bugs before merge, and deploys automatically.

The goal

On every pull request: lint, test, build. On merge to main: deploy. Under 30 minutes to set up. Copy and adapt.

Step 1: Create the workflow file

.github/workflows/ci.yml

Step 2: The CI pipeline

name: CI

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

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Test
        run: npm test -- --coverage

      - name: Build
        run: npm run build

That’s it for CI. Every PR now runs all four steps in parallel jobs if you split them, or sequentially here for simplicity.

Step 3: Deploy on merge to main

Add a deploy job that depends on ci:

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm run build

      - name: Deploy to production
        run: npx your-deploy-cli deploy ./dist
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Add DEPLOY_TOKEN to GitHub → Settings → Secrets.

Caching dependencies

The cache: 'npm' in setup-node caches ~/.npm between runs. On a warm cache, npm ci goes from 90s to 8s on a typical project.

Useful additions

Matrix testing across Node versions:

strategy:
  matrix:
    node-version: ['20', '22']

Fail fast if coverage drops:

- name: Test with coverage threshold
  run: npm test -- --coverage --coverageThreshold='{"global":{"lines":80}}'

Notify on failure (Slack):

- name: Notify Slack
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: '{"text":"❌ Build failed on ${{ github.ref }}"}'
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Common mistakes

  1. Not pinning action versions. Use @v4 not @main. Unpinned actions can silently break.
  2. Storing secrets in the workflow file. Always use ${{ secrets.NAME }}.
  3. Running everything in one job. Split lint/test/build into parallel jobs to cut wall-clock time.
  4. Not caching. The cache step pays for itself on the first warm run.

The result

Every PR gets a green check or a red X before anyone reviews it. Merging to main ships to production automatically. The whole thing costs $0 on GitHub’s free tier for public repos, and ~$0.008 per minute for private repos.

That’s the baseline every project should have.