Setting Up a Production-Grade CI/CD Pipeline for Node.js with GitHub Actions

Beyond the basic "build and push" tutorial — secrets management, real integration tests, Docker image caching, and a deploy that verifies itself

•
6 min read
Setting Up a Production-Grade CI/CD Pipeline for Node.js with GitHub Actions

Most tutorials show you how to make a GitHub Actions workflow. This one shows you how to make one that is actually fit for production — with environment separation, secret management, test gates, Docker builds, and a deployment that does not make you nervous.

The stack: Node.js app, Docker, GitHub Container Registry (GHCR), and a VPS running Nginx. No third-party deployment services. No surprise invoices.


What the Pipeline Does

On every push to main:

  1. Runs linting and type checks

  2. Runs the test suite

  3. Builds a Docker image and pushes it to GHCR

  4. SSHes into the VPS and pulls + restarts the container

  5. Verifies the health check passes before marking the deploy successful

On pull requests:

  1. Runs linting and tests

  2. Does NOT deploy anywhere


Project Structure

/your-app
├── src/
├── tests/
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── .github/
    └── workflows/
        ├── ci.yml          # runs on all PRs and pushes
        └── deploy.yml      # runs only on push to main

Step 1 — The Dockerfile

A production Dockerfile should use multi-stage builds to keep the final image small and not include dev dependencies.

# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

# If you have a build step (TypeScript, etc.)
RUN npm run build

# Stage 2: Production image
FROM node:20-alpine AS production

# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only what we need from the builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./

USER appuser

EXPOSE 3000

# Health check built into the image
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

CMD ["node", "dist/index.js"]

Key decisions here:

  • Multi-stage build keeps the image lean (often 3-4x smaller)

  • Non-root user runs the app (appuser) — important for container security

  • npm ci instead of npm install — deterministic, respects lockfile

  • Health check baked into the image so Docker itself knows when the container is ready


Step 2 — The CI Workflow (Runs on Every PR)

.github/workflows/ci.yml

name: CI

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

jobs:
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest

    services:
      # Spin up a Postgres instance for integration tests
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run type check
        run: npm run typecheck

      - name: Run tests
        run: npm test
        env:
          NODE_ENV: test
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          JWT_SECRET: test-secret-not-real

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7

A few things worth noting:

  • The Postgres services block spins up a real database for tests, not a mock. Integration tests that hit a real DB catch far more bugs than unit tests with mocked queries.

  • cache: "npm" caches node_modules between runs — often saves 1-2 minutes.

  • Coverage report is uploaded as an artifact even on failure, so you can debug what broke.


Step 3 — The Deploy Workflow (Runs Only on Main)

.github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: false  # Never cancel an in-flight production deploy

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # Re-run tests before deploying — never skip this
  test:
    name: Run Tests
    uses: ./.github/workflows/ci.yml

  build:
    name: Build & Push Image
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build-push.outputs.digest }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        id: build-push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # Cache layers from the previous build — speeds up rebuilds significantly
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    name: Deploy to Production
    needs: build
    runs-on: ubuntu-latest
    environment: production   # Enables environment protection rules in GitHub

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_SSH_PORT }}  # your custom SSH port
          script: |
            set -e

            IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"

            echo "Pulling $IMAGE..."
            docker pull $IMAGE

            echo "Stopping old container..."
            docker stop app 2>/dev/null || true
            docker rm app 2>/dev/null || true

            echo "Starting new container..."
            docker run -d \
              --name app \
              --restart unless-stopped \
              --env-file /opt/app/.env.production \
              -p 3000:3000 \
              $IMAGE

            echo "Waiting for health check..."
            sleep 5
            RETRIES=0
            until docker inspect --format='{{.State.Health.Status}}' app | grep -q "healthy"; do
              RETRIES=$((RETRIES + 1))
              if [ $RETRIES -ge 10 ]; then
                echo "Container failed health check. Rolling back..."
                docker stop app
                docker rm app
                # Restart from the previous image tag if you stored it
                exit 1
              fi
              echo "Not healthy yet ($RETRIES/10)..."
              sleep 5
            done

            echo "Deploy successful."

            # Clean up old images to save disk space
            docker image prune -f

Step 4 — Secrets Setup in GitHub

Go to Settings → Secrets and variables → Actions → New repository secret:

Secret

Value

VPS_HOST

Your server IP or domain

VPS_USER

deploy (the non-root deploy user)

VPS_SSH_KEY

Contents of your ~/.ssh/id_ed25519 private key

VPS_SSH_PORT

Your custom SSH port (e.g. 2222)

Also go to Settings → Environments and create a production environment. You can add required reviewers here so deploys to production require manual approval — useful for teams.


Step 5 — The .env File on Your VPS

Your production secrets live on the VPS only, never in your repo or your Docker image.

# On your VPS at /opt/app/.env.production
DATABASE_URL=postgresql://user:password@localhost:5432/yourdb
JWT_SECRET=a-long-random-string-generated-with-openssl-rand-hex-32
NODE_ENV=production
PORT=3000

Generate a proper JWT secret:

openssl rand -hex 32

Set correct permissions:

chmod 600 /opt/app/.env.production
chown deploy:deploy /opt/app/.env.production

Step 6 — Nginx as the Front Door

Your Docker container listens on port 3000. Nginx handles SSL and proxies to it.

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Recommended SSL settings
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain application/json application/javascript text/css;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Get your SSL certificate with Certbot:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com
# Certbot auto-configures renewal via a systemd timer

Common Problems and Their Fixes

The deploy fails with "permission denied" on Docker

# On the VPS, add your deploy user to the docker group
sudo usermod -aG docker deploy
# Log out and back in for the group change to take effect

Container starts but health check keeps failing Check the container logs before blaming the health check:

docker logs app --tail 50

Nine times out of ten, it is a missing environment variable or a database connection issue, not the health check itself.

Old images filling up disk space Add a cron job to the deploy user:

# crontab -e (as deploy user)
0 3 * * * docker image prune -f >> /var/log/docker-prune.log 2>&1

GitHub Actions runner can't reach your VPS Check your UFW rules — make sure port 22 (or your custom SSH port) allows connections from GitHub Actions runner IP ranges. GitHub publishes these at https://api.github.com/meta.


What You End Up With

A git push to main that:

  • Runs your full test suite including real database integration tests

  • Builds a minimal, secure Docker image

  • Pushes it to GitHub's own container registry (free for public repos)

  • Deploys to your VPS and verifies it is healthy before marking success

  • Keeps secrets out of your repo, out of your image, and off your CI logs

Total external cost: your VPS. Everything else is free.

Comments (0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.