GitHub Actions CI/CD Pipeline: From Zero to Production

July 19, 2026 · 8 min read

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 }}

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/

Key details that matter:

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

Three things here that beginners get wrong:

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'

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"

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

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

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-

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

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

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.