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:
| Container | Memory (Idle) | Memory (Under Load) | CPU (Idle) |
|---|---|---|---|
| Node.js app (Express) | 60–80 MB | 120–200 MB | <1% |
| PostgreSQL | 40–60 MB | 150–300 MB | <1% |
| Redis | 5–10 MB | 30–60 MB | <1% |
| Nginx | 10–15 MB | 20–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 installinstead ofnpm ci:npm installmodifies package-lock.json.npm ciinstalls exactly what the lock file specifies and is faster. Usenpm ciin Dockerfiles. - Not pinning base image tags:
node:20-alpineis pinned to Alpine and Node 20, but not to a specific Alpine version. For stricter reproducibility, usenode: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
You must be logged in to post a comment.