DEV Community

toothbrush
toothbrush

Posted on • Originally published at toothbrushroot.github.io

GitHub Actions CI/CD Pipeline: From Zero to Production

Most teams start with GitHub Actions because it's already there — your code is on GitHub, so the CI runner is a checkbox away. But a real production pipeline is more than running npm test on every push. It needs caching, Docker builds, multi-stage environments, artifact management, and deployment that doesn't wake you up at 3 AM.

I've built and broken enough pipelines to know what works. Here's a production-grade GitHub Actions workflow — explained piece by piece so you understand why each part exists.

1. The Workflow Skeleton

Every GitHub Actions workflow lives in .github/workflows/. We'll build a single file called ci-cd.yml that handles test, build, and deploy for a Node.js + Docker application.

name: CI/CD Pipeline

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
Enter fullscreen mode Exit fullscreen mode

The on.push trigger runs on main-branch commits. The pull_request trigger runs on PRs targeting main — so you catch failures before merge. The env block defines shared variables so you don't repeat yourself in every step.

2. Job 1: Lint & Test (Fast Feedback)

Your first job should finish in under 2 minutes. Speed matters because this is what runs on every PR commit — slow tests kill developer productivity.

jobs:
  lint-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 run lint
      - run: npm test -- --coverage

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
Enter fullscreen mode Exit fullscreen mode

Key details that matter:

  • npm ci — not npm install. ci uses the lockfile exactly, fails if lockfile ≠ package.json, and is 2-3x faster. Never use npm install in CI.
  • cache: 'npm' — built into setup-node. Caches ~/.npm based on package-lock.json. Cuts install time from 45s to 8s.
  • upload-artifact with if: always() — coverage reports are useful even when tests fail. You can browse them in the Actions UI.
  • Separation of concerns — this job has nothing to do with Docker or deployment. It fails fast and tells the developer "your code is broken" before anything expensive runs.

3. Job 2: Docker Build & Scan

The second job builds the Docker image. Crucially, it waits for lint-test to pass. GitHub Actions uses needs for this.

  docker-build:
    needs: lint-test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub 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@v6
        with:
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

Three things here that beginners get wrong:

  • Two tagslatest lets you always pull the newest. The sha tag lets you deploy a specific version and roll back. Without the SHA tag, you can't tell which commit is running in production.
  • cache-from/cache-to: type=gha — this is GitHub Actions' magic cache for Docker layers. Without it, every build starts from scratch. With it, only changed layers rebuild. A 3-minute build drops to 40 seconds on the second run.
  • permissions: packages: write — the default GITHUB_TOKEN can't push packages. You must explicitly grant write access to the GitHub Container Registry.

4. Job 3: Security Scan

Don't deploy vulnerable images. Add a scan step between build and deploy:

  security-scan:
    needs: docker-build
    runs-on: ubuntu-latest
    steps:
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'
          category: 'trivy'
Enter fullscreen mode Exit fullscreen mode

Trivy scans your image against known CVE databases. If someone added a vulnerable dependency, you'll see it in GitHub's Security tab before it ever hits production. I've caught 4 critical CVEs this way — including an undici HTTP request smuggling vulnerability that wasn't flagged by npm audit.

5. Job 4: Deploy

Deployment is the most environment-specific part. I'll show a generic SSH-based deploy to a VPS, which applies to most small-to-mid-size setups:

  deploy:
    needs: [security-scan]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/my-app
            docker compose pull
            docker compose up -d --remove-orphans
            docker system prune -af --filter "until=24h"
Enter fullscreen mode Exit fullscreen mode

This runs docker compose pull on your server (it fetches the updated image you just pushed), recreates containers, and cleans up old images that are more than 24 hours old. The --remove-orphans flag removes containers defined in the compose file that are no longer running — prevents stale container drift.

The deploy job only runs on the main branch (if: github.ref == 'refs/heads/main'). PR builds stop at security-scan. This is deliberate — you want to know the image is safe before you deploy, but the actual deploy only happens when code merges.

6. Environment Protection Rules

Don't deploy to production just because someone pushed to main. Set up environment protection in your repo settings:

      - name: Deploy to Production
        uses: appleboy/ssh-action@v1.0.3
        with:
          # ...
        environment: production
Enter fullscreen mode Exit fullscreen mode

Then in GitHub → Settings → Environments → production, require:

  • Required reviewers — a senior dev must approve the deploy
  • Wait timer — 5 minutes. Gives you a window to cancel if the pipeline just ran on the wrong branch
  • Deployment branches — only main can deploy to production

This single setting has saved me from deploying broken code more times than I'd like to admit. The 5-minute wait is surprisingly effective — it forces you to sit and watch the first few seconds of deployment logs.

7. Caching: The Single Biggest Speed Win

A pipeline without caching is a slow pipeline. Beyond Docker layer caching, use GitHub's generic cache action for everything else:

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            node_modules
            .next/cache
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-
Enter fullscreen mode Exit fullscreen mode

This caches npm dependencies AND the Next.js (or similar) build cache. Combined with Docker layer caching, a full pipeline that took 8 minutes now runs in under 3. Your team will stop complaining about slow CI.

8. Matrix Builds (Bonus)

If your app supports multiple runtimes, use a matrix strategy to test them all in parallel:

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

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

Three parallel runners, each testing a different Node version. They all run simultaneously and the needs condition for downstream jobs only waits for this job to succeed on all matrix entries. If Node 22 has a regression, you'll know before your users do.

Putting It All Together

The final workflow has four sequential stages:

lint-test → docker-build → security-scan → deploy
Enter fullscreen mode Exit fullscreen mode

Each stage gates the next. If linting fails, nothing builds. If the image has critical CVEs, nothing deploys. If the deploy fails, the previous version keeps running because docker compose up only restarts containers when the image actually changes.

The total file is about 90 lines. Copy it into .github/workflows/ci-cd.yml, replace the deploy script with whatever your server needs, and you have a production CI/CD pipeline that handles caching, security scanning, and safe deployment.

One final thing: test your pipeline on a branch before pushing to main. Every time I skip this, I end up fixing YAML indentation via 20 commits to a broken main. Push your workflow to a feature branch first, open a PR, and watch the pipeline run before you merge.

Top comments (0)