ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeSetting Up a Production-Grade CI/CD Pipeline for Node.js with GitHub Actions

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

ZyVOP
ZyVOP
Senior Developer
May 23, 2026
6 min read
Setting Up a Production-Grade CI/CD Pipeline for Node.js with GitHub Actions
#Node.js production deployment#GitHub Actions secrets management#GitHub Actions CI/CD Node.js#Docker deployment VPS#self-hosted CI/CD 2026#GHCR GitHub Container Registry

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)

Login to post a comment.

ZyVOP
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

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1