ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

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

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
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

#GitHub Actions CI/CD Node.js#Docker deployment VPS#GHCR GitHub Container Registry#Node.js production deployment#self-hosted CI/CD 2026#GitHub Actions secrets management
Z
ZyVOP

Senior Developer

May 23, 2026
7 min read
10 views
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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design