Deployment
Production deployment patterns for Node.js — PM2 cluster mode, Nginx reverse proxy, Docker containerization, environment variable management, and health check endpoints.
PM2 – ecosystem.config.js
PM2 manages Node.js processes in production. Use cluster mode to spawn one worker per CPU core, enabling zero-downtime reloads and automatic restart on crash.
npm install -g pm2
// ecosystem.config.js (project root)
module.exports = {
apps: [
{
name: 'api',
script: './src/server.js',
instances: 'max', // one per CPU core
exec_mode: 'cluster', // share port across workers
watch: false, // disable in production
max_memory_restart: '512M', // restart if memory exceeds 512 MB
env: {
NODE_ENV: 'development',
PORT: 3000,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
// Logging
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: './logs/pm2-error.log',
out_file: './logs/pm2-out.log',
merge_logs: true,
// Graceful shutdown
kill_timeout: 5000, // wait 5s before SIGKILL
listen_timeout: 10000, // wait 10s for app to be ready
},
],
};PM2 Essential Commands
Common PM2 commands for starting, monitoring, reloading, and saving process state across server reboots.
# Start with ecosystem config (production env)
pm2 start ecosystem.config.js --env production
# Zero-downtime reload (cluster mode only)
pm2 reload api
# Full restart
pm2 restart api
# View logs (tail -f style)
pm2 logs api --lines 100
# Monitor dashboard
pm2 monit
# List all processes
pm2 list
# Save process list (survives server reboot)
pm2 save
# Auto-start PM2 on server reboot
pm2 startup systemd
# Follow the printed command, e.g.:
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu
# Delete app from PM2
pm2 delete apiNginx Reverse Proxy Configuration
Nginx sits in front of Node.js, handles SSL termination, serves static files, and forwards API requests upstream. Always set the X-Forwarded-* headers so Node sees the real client IP.
# /etc/nginx/sites-available/myapp.conf
upstream nodejs {
# Round-robin across PM2 cluster workers
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name myapp.com www.myapp.com;
# Redirect HTTP → HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com www.myapp.com;
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Serve static files directly
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Proxy API requests to Node.js
location / {
proxy_pass http://nodejs;
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;
proxy_read_timeout 60s;
}
# Rate limiting (optional)
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
location /api/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://nodejs;
}
}Dockerfile for Node.js (Multi-Stage Build)
Multi-stage builds keep the production image lean — only production dependencies and compiled assets are included. Run as a non-root user for security.
# ── Stage 1: Install dependencies ───────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# ── Stage 2: Production image ────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
# Security: run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
# Create log directory with correct permissions
RUN mkdir -p logs && chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]docker-compose.yml (Production Stack)
Orchestrate Node.js, MySQL, and Redis together. Use named volumes for data persistence and depends_on with health checks to control startup order.
# docker-compose.yml
version: '3.9'
services:
api:
build: .
ports: ['3000:3000']
env_file: .env.production
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
restart: unless-stopped
volumes:
- ./logs:/app/logs
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
volumes:
mysql_data:
redis_data:Health Check Endpoint
Expose a /health endpoint for load balancers, container orchestrators (Kubernetes, ECS), and uptime monitors. Check dependencies (DB, Redis) and return their status.
// routes/health.js
const express = require('express');
const router = express.Router();
const db = require('../db');
const { redis } = require('../lib/redis');
router.get('/health', async (req, res) => {
const start = Date.now();
const checks = {};
// Check MySQL
try {
await db.query('SELECT 1');
checks.mysql = 'ok';
} catch (err) {
checks.mysql = `error: ${err.message}`;
}
// Check Redis
try {
await redis.ping();
checks.redis = 'ok';
} catch (err) {
checks.redis = `error: ${err.message}`;
}
const allOk = Object.values(checks).every(v => v === 'ok');
const statusCode = allOk ? 200 : 503;
res.status(statusCode).json({
status: allOk ? 'healthy' : 'degraded',
uptime: process.uptime(),
responseMs: Date.now() - start,
checks,
version: process.env.npm_package_version,
env: process.env.NODE_ENV,
});
});
// Lightweight liveness probe (no dependency checks)
router.get('/ping', (_req, res) => res.send('pong'));
module.exports = router;Environment Variables in Production
Never commit .env to version control. Use a .env.example as documentation. In production, inject secrets via your platform (AWS Secrets Manager, Vault, CI/CD env vars).
# .env.example (commit this – no real values!)
NODE_ENV=production
PORT=3000
# Database
DB_HOST=localhost
DB_PORT=3306
DB_NAME=myapp_prod
DB_USER=myapp_user
DB_PASSWORD=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# JWT
JWT_SECRET=
JWT_EXPIRES_IN=7d
# Mailer
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USER=
MAIL_PASS=
MAIL_FROM="MyApp <noreply@myapp.com>"
# Logging
LOG_LEVEL=info