Containerizing a Node.js Application with Docker on a VPS: Multi-Stage Builds, Health Checks, and Deployment

Running Node.js directly on a VPS works until you need to manage dependencies, Node.js versions, environment variables, and process restart behavior across deploys. Docker solves these problems by packaging everything into a reproducible container. This article covers multi-stage Docker builds for small production images, health checks that catch application failures before users do, and a deployment workflow that minimizes downtime.

Why Docker on a Single VPS

Docker on a single VPS is not Kubernetes. It is a packaging and isolation tool that gives you:

  • Reproducible builds: The same Dockerfile produces the same container on any VPS, your laptop, or CI.
  • Dependency isolation: Your Node.js app runs with its own dependencies. No conflicts with the host OS or other applications.
  • Process supervision: Docker handles restart policies, health checks, and log management — no need for pm2 or systemd for the application itself.
  • Resource limits: Enforce CPU and memory limits per container, preventing one application from starving others.

Step 1: Multi-Stage Dockerfile

A multi-stage build separates the build environment from the runtime environment. The final image contains only the production dependencies and the compiled application, not the build tools or source code:

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

WORKDIR /app

# Install dependencies (separate layer for caching)
COPY package*.json ./
RUN npm ci --only=production && \
    cp -R node_modules /tmp/node_modules

# Install all dependencies for build
RUN npm ci

# Copy source and build
COPY . .
RUN npm run build

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

RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

WORKDIR /app

# Copy only production artifacts
COPY --from=builder /tmp/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./

# Set non-root user
USER appuser

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

EXPOSE 3000

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

This Dockerfile produces a final image of approximately 80–120 MB, compared to 400–600 MB for a single-stage build. The build stage compiles TypeScript and bundles assets. The production stage copies only the compiled output and production dependencies.

Step 2: Health Check Endpoint

The Docker HEALTHCHECK directive in the Dockerfile above calls a /health endpoint. Your application must implement this endpoint. It should return a 200 status code when the application is healthy and a 5xx status code when it is not:

// Express.js health check endpoint
app.get('/health', (req, res) => {
    const checks = {
        database: await checkDatabase(),
        redis: await checkRedis(),
        uptime: process.uptime()
    };

    const allHealthy = Object.values(checks).every(c => c !== false);
    const status = allHealthy ? 200 : 503;

    res.status(status).json({
        status: allHealthy ? 'healthy' : 'unhealthy',
        timestamp: new Date().toISOString(),
        checks
    });
});

async function checkDatabase() {
    try {
        await pool.query('SELECT 1');
        return true;
    } catch {
        return false;
    }
}

async function checkRedis() {
    try {
        await redis.ping();
        return true;
    } catch {
        return false;
    }
}

Docker will mark the container as unhealthy after 3 failed health checks. If you use Docker Swarm or a reverse proxy that respects health checks, unhealthy containers are automatically removed from the load balancer pool.

Step 3: Docker Compose for Production

Use Docker Compose to define the application stack — the Node.js app, a reverse proxy, and any databases:

# docker-compose.yml
version: "3.8"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: node-app
    restart: unless-stopped
    expose:
      - "3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://app_user:${DB_PASSWORD}@db:5432/app_db
      - REDIS_URL=redis://redis:6379
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "0.5"
        reservations:
          memory: 128M
          cpus: "0.25"
    networks:
      - backend

  db:
    image: postgres:16-alpine
    container_name: postgres
    restart: unless-stopped
    environment:
      - POSTGRES_USER=app_user
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=app_db
    volumes:
      - pgdata:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "0.5"
    networks:
      - backend

  redis:
    image: redis:7-alpine
    container_name: redis
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    deploy:
      resources:
        limits:
          memory: 96M
          cpus: "0.25"
    networks:
      - backend

networks:
  backend:
    driver: bridge
    internal: true

volumes:
  pgdata:
  redis_data:

Step 4: Deployment Workflow

Set up a deployment script that pulls the latest code, rebuilds the container, and restarts the service with minimal downtime:

#!/bin/bash
# deploy.sh - Production deployment
set -euo pipefail

cd /opt/myapp

# Pull latest code
git pull origin main

# Build the new image
docker compose build app

# Restart only the app service (database stays running)
docker compose up -d --no-deps app

# Wait for health check to pass
for i in {1..30}; do
    if curl -sf http://localhost:3000/health > /dev/null; then
        echo "App is healthy"
        break
    fi
    echo "Waiting for app to become healthy... ($i/30)"
    sleep 2
done

# Clean up old images
docker image prune -f

echo "Deployment complete"

--no-deps restarts only the app container, leaving the database and Redis containers running. This reduces downtime to the time it takes for the new container to start and pass its health check — typically 5–10 seconds.

Step 5: Reverse Proxy with Nginx

Add an Nginx reverse proxy to handle TLS termination, static file serving, and load balancing between multiple app instances:

# nginx/conf.d/app.conf
server {
    listen 80;
    server_name yourdomain.com;

    # Static files served directly by Nginx
    location /static/ {
        root /var/www/app;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Proxy to Node.js app
    location / {
        proxy_pass http://node-app: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_read_timeout 60s;
    }
}

Add the reverse proxy service to your docker-compose.yml:

  reverse-proxy:
    image: nginx:1.25-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/www:/var/www/certbot:ro
      - ./certbot/conf:/etc/letsencrypt:ro
    networks:
      - frontend
      - backend
    depends_on:
      app:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 64M
          cpus: "0.25"

Resource Requirements on a VPS

Here is the memory footprint of a typical Node.js + PostgreSQL + Redis + Nginx stack on a VPS:

ContainerMemory (Idle)Memory (Under Load)CPU (Idle)
Node.js app (Express)60–80 MB120–200 MB<1%
PostgreSQL40–60 MB150–300 MB<1%
Redis5–10 MB30–60 MB<1%
Nginx10–15 MB20–30 MB<1%
Total~115–165 MB~320–590 MB<1%

This stack fits comfortably on a 1 GB VPS and leaves room for log files and system processes. On a 2 GB VPS, you can run multiple instances of the app for redundancy.

Common Pitfalls

  • Using npm install instead of npm ci: npm install modifies package-lock.json. npm ci installs exactly what the lock file specifies and is faster. Use npm ci in Dockerfiles.
  • Not pinning base image tags: node:20-alpine is pinned to Alpine and Node 20, but not to a specific Alpine version. For stricter reproducibility, use node:20.11.0-alpine3.19.
  • Running as root: The HEALTHCHECK directive runs as the container user. If your app runs as root (default), a compromised app has full container access. Always set a non-root user.
  • Not setting resource limits: Without deploy.resources.limits, a memory leak in the Node.js app can consume all VPS RAM and trigger the OOM killer.
  • Volume permissions: Named volumes created by Docker are owned by root. If your app runs as a non-root user, it cannot write to the volume. Initialize volumes with correct ownership in an entrypoint script.

When choosing a VPS for your containerized Node.js application, compare providers with adequate RAM and SSD storage to handle your containerized stack. For more on Docker and VPS deployments, explore our VPS tutorials.

Leave a Reply