Node.js

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 api

Nginx 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