General Published Aug 12, 2026 · ⏱️ 26 min read · 👁️ views

How to Deploy Node.js Applications to Production: PM2, Docker, Nginx & SSL

A complete, step-by-step guide to deploying Node.js apps to production. Learn how to set up PM2, Nginx, Caddy, SSL/HTTPS certificates, database sidecars, and SiliconPin Pods

How to Deploy Node.js Applications to Production: PM2, Docker, Nginx & SSL

Moving a Node.js app from your laptop (localhost:3000) to a live production server is usually when reality hits. In development, running node index.js in a terminal is fine. But in production, a single uncaught exception can crash your entire process—taking down your API for every single user in an instant.

Deploying Node.js properly is about more than just copying files to a remote box and starting a process. You need a self-healing environment that stays online 24/7, handles sudden traffic spikes, manages SSL certificates, secures application secrets, and recovers automatically when things inevitably fail.


1. How Node.js Behaves in Production

Before typing server commands, it helps to look at how Node actually runs under the hood.

Node.js runs your JavaScript code on a single main thread using an Event Loop. When your code needs to do heavy I/O—like reading a file or querying a database—it offloads that work to a system-level thread pool managed by libuv. This is fundamentally different from traditional servers like Apache, which spin up a new thread for every incoming HTTP request.

CODE
[ Incoming Requests ] ──> [ Event Loop (Single Thread) ] ──> [ Offload I/O to libuv Pool ]
                                                                     
                                                                     
[ Response Sent ]     <── [ Event Loop Callback Executed ] <── [ Disk / Database / Network ]

Concurrency and Event Loop Trade-offs

This design gives Node.js distinct runtime characteristics:

  • What Node does well: It handles thousands of concurrent I/O-bound connections (database queries, HTTP requests, WebSockets) using very little memory.
  • Where Node can break: Because JavaScript runs on one thread, any heavy CPU work (like parsing a 50MB JSON payload or running an intensive loop) blocks the event loop. If an uncaught error throws, the entire server process dies.

That is why you should never expose a bare Node.js process directly to the internet. You always need a process manager to restart the app when it crashes and a reverse proxy to handle SSL, static assets, and client connections.


2. Choosing Your Infrastructure

Where should you host your application? You generally have three main options depending on your budget, team size, and control needs:

Strategy Pros Cons
Self-Managed VPS (DigitalOcean, Hetzner, AWS EC2) Cheap, fast, and gives you total control over hardware and OS settings. You handle OS updates, firewall rules, backups, and server setup yourself.
PaaS (Render, Railway, Heroku) Git push-to-deploy, automatic SSL, zero server maintenance. Gets expensive quickly as traffic grows; limited access to underlying OS settings.
Serverless / Edge (AWS Lambda, Vercel) Costs nothing when idle, scales automatically during massive spikes. Cold-start latency, strict execution timeouts, unusable for WebSockets or long connections.

PM2 vs. Docker: Which Path Should You Take?

For VPS deployments, you have two primary paths:

  • PM2 (Bare VPS): Fast to set up. Runs directly on the host OS and uses built-in clustering to utilize all CPU cores with one command. Ideal for single-instance, monolithic apps.
  • Docker (Containers): Packages your code, Node version, and system dependencies into an isolated container. If it runs on your laptop, it will run in production. However, scaling containers across CPU cores requires running multiple container replicas behind a load balancer (usually managed via Docker Compose).

Comparison Matrix

Feature PM2 (Bare VPS) Docker (Containers)
Concept Process manager that keeps Node instances running. OS-level virtualization packaging code + OS environment.
Isolation None. Apps share the host filesystem, ports, and packages. High. Each container runs in its own isolated namespace.
Learning Curve Low. Standard npm commands and npm install -g pm2. Moderate. Requires learning Dockerfiles, image caching, and Compose.
Environment Parity Low. Host OS differences can lead to bugs. Absolute. Identical image runs locally, in CI/CD, and on servers.
Clustering Built-in. PM2 cluster mode spawns workers for all CPU cores. Manual. Requires scaling replicas via Docker Compose or Orchestrators.
Resource Overhead Near zero. Runs directly on the host system. Minimal, but slightly higher due to container engine overhead.
Best For Independent developers, side projects, and monolithic APIs on a single VPS. Microservices, multi-service setups (Node + Redis + Postgres), enterprise pipelines.

If you want the simplest bare-VPS setup, follow the PM2 & Nginx/Caddy Blueprint below. If you prefer container isolation, jump to the Docker & Docker Compose Blueprint.


3. Step-by-Step Deployment Guide

Here is how to set up your app on a fresh Ubuntu 24.04 LTS server from scratch.

Step 1: Secure Your Server

First rule: never run Node as root. If an attacker finds a remote code execution flaw in your application, running as root gives them complete control over your entire server.

SSH into your server, update packages, and set up a non-root user with sudo privileges:

BASH
# Update system packages
sudo apt update && sudo apt upgrade -y

# Create a dedicated deployment user
sudo adduser deployer
sudo usermod -aG sudo deployer

# Switch to the new user
su - deployer

# Enable firewall and allow SSH, HTTP, and HTTPS ports
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Step 2: Install Node.js using NVM

Avoid Ubuntu’s default apt Node package—it is almost always outdated. Use NVM (Node Version Manager) to install and switch to the exact LTS version you need:

BASH
# Install NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# Load NVM into current bash session and install Node LTS
source ~/.bashrc
nvm install --lts
nvm use --lts

Step 3: Clone Your App & Lock Down .env Permissions

Clone your code, install dependencies using npm ci --omit=dev (which skips devDependencies), and set up your environment configuration.

BASH
git clone https://github.com/your-username/your-node-app.git
cd your-node-app

# Install only production dependencies
npm ci --omit=dev

# Create your production environment file
nano .env

Ensure your application listens locally on 127.0.0.1 on an internal port like 3000. Do not expose this port to public traffic—Nginx will handle external connections.

IMPORTANT

1. Set NODE_ENV=production inside .env.
Frameworks like Express disable verbose debug logging, cache templates in memory, and optimize code paths when NODE_ENV is set to production—boosting throughput by up to 3x.

2. Restrict .env permissions.
Lock down your environment file so other unprivileged system users cannot read your secret keys:

BASH
chmod 600 .env

Pro Tip: For team environments, avoid manually editing .env files on disk. Use centralized secret managers like Infisical, Doppler, or AWS Secrets Manager to inject variables into processes at startup.

Step 4: Configure PM2 using ecosystem.config.js

While running pm2 start index.js via CLI works for quick tests, production setups should use an ecosystem.config.js file tracked in Git. This guarantees consistent settings across deployments.

Create ecosystem.config.js in your project root:

JAVASCRIPT
module.exports = {
  apps: [{
    name: 'node-api',
    script: './index.js',
    
    // Auto-scale workers across all available CPU cores
    instances: 'max',
    exec_mode: 'cluster',
    
    // Wait for explicit readiness signal before routing traffic
    wait_ready: true,
    listen_timeout: 10000,
    kill_timeout: 5000,
    
    // Auto-restart worker if memory leaks past 500MB
    max_memory_restart: '500M',
    
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};

In your application entry point (index.js), notify PM2 once your database connections and Express routes are ready:

JAVASCRIPT
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  
  // Send readiness signal to PM2 cluster master
  if (process.send) {
    process.send('ready');
  }
});

Start the application and configure PM2 to resurrect processes automatically if the server reboots:

BASH
# Install PM2 globally
npm install -g pm2

# Start your app using the ecosystem file
pm2 start ecosystem.config.js --env production

# Save process list and generate systemd startup hook
pm2 startup
# (Run the exact command PM2 prints to your terminal)
pm2 save

Prevent full disks with pm2-logrotate:

By default, PM2 logs all stdout/stderr output to disk indefinitely. To stop log files from consuming all your server storage, install the log rotation module:

BASH
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 10
pm2 set pm2-logrotate:compress true

Step 5: Configure Nginx as a Reverse Proxy

Running Node directly on port 80 or 443 is a bad idea—it lacks the security, static asset handling, and performance optimizations of a dedicated web server. Instead, put Nginx in front of Node as a reverse proxy.

BASH
sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/yourdomain.com

Paste in this server block:

NGINX
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    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_cache_bypass $http_upgrade;
        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;
    }
}

Enable the configuration and reload Nginx:

BASH
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
TIP

Enable app.set('trust proxy', true) in Express.
Because Nginx sits between the user and Node, Express sees all incoming connections as originating from 127.0.0.1. Setting trust proxy instructs Express to read the X-Forwarded-For header populated by Nginx so req.ip returns the client’s actual IP address.

Step 6: Secure Your Site with Free SSL (Certbot)

HTTPS is mandatory in production. Use Certbot to issue a free SSL certificate from Let’s Encrypt and automatically configure Nginx to redirect HTTP traffic to HTTPS:

BASH
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Alternative: Using Caddy Server (Zero-Config HTTPS)

If you prefer to avoid manual Nginx configurations and SSL setup, Caddy is a great alternative. Caddy automatically provisions Let’s Encrypt SSL certificates and handles reverse proxying in a 3-line configuration file.

Install Caddy on Ubuntu:

BASH
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy -y

Edit /etc/caddy/Caddyfile:

CADDY
yourdomain.com {
    reverse_proxy 127.0.0.1:3000
}

Restart Caddy:

BASH
sudo systemctl restart caddy

Alternative: Docker & Docker Compose Blueprint

If you prefer containerized isolation, package your application into Docker. This guarantees that your app runs in an identical environment locally, in CI/CD, and on production servers.

Install Docker on your server:

BASH
sudo apt update
sudo apt install docker.io docker-compose -y
sudo systemctl enable --now docker

1. Production Dockerfile

Create a Dockerfile in your project root using a multi-stage build to keep image sizes small and run the app safely as an unprivileged node user:

DOCKERFILE
# Stage 1: Install dependencies and build code
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# RUN npm run build

# Stage 2: Clean production runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy package files and install ONLY production dependencies
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev

# Copy application source code from builder stage
COPY --chown=node:node --from=builder /app/index.js ./index.js
# COPY --chown=node:node --from=builder /app/dist ./dist

# Run process as non-root user
USER node

EXPOSE 3000
CMD ["node", "index.js"]

2. docker-compose.yml File

Create docker-compose.yml to run your service and map environment variables:

YAML
version: '3.8'

services:
  web:
    build: .
    restart: always
    # Bind container port 3000 only to localhost on the host machine
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
    env_file:
      - .env
NOTE

Binding to 127.0.0.1:3000:3000 keeps your container hidden behind your host’s Nginx/Caddy server, which acts as the public SSL gateway.

3. Build and Run Container

BASH
# Build image and start container in background
sudo docker-compose up --build -d

# Check running status and view logs
sudo docker-compose ps
sudo docker-compose logs -f

4. Automating Deployments (CI/CD Pipelines)

Manually logging into servers to run git pull and restart services is error-prone. Production deployments should always be automated.

Option A: Push-to-Deploy via Git Hooks

You can set up a Git bare repository on your VPS so running git push production main from your laptop triggers an automated deployment hook on the server.

1. Set Up Server Bare Repo

BASH
# Create bare repository and live working directory
sudo mkdir -p /var/repo/my-app.git /var/www/my-app
sudo chown -R $USER:$USER /var/repo /var/www

# Initialize bare repository
cd /var/repo/my-app.git
git init --bare

2. Create post-receive Hook Script

Create /var/repo/my-app.git/hooks/post-receive and make it executable (chmod +x post-receive):

BASH
#!/bin/bash
set -e

export PATH=$PATH:/usr/local/bin:/usr/bin:/bin
if [ -d "$HOME/.nvm/versions/node" ]; then
    NODE_LATEST=$(ls $HOME/.nvm/versions/node | tail -n 1)
    export PATH=$HOME/.nvm/versions/node/$NODE_LATEST/bin:$PATH
fi

TARGET="/var/www/my-app"
APP_NAME="node-api"

echo "📦 Checking out latest main branch..."
GIT_WORK_TREE=$TARGET git checkout -f main
cd $TARGET

echo "📥 Installing production dependencies..."
npm ci --omit=dev

echo "🔄 Reloading PM2 with zero downtime..."
pm2 reload ecosystem.config.js --env production || pm2 start ecosystem.config.js --env production

3. Deploy from Local Machine

BASH
git remote add production deployer@your-server-ip:/var/repo/my-app.git
git push production main

Option B: GitHub Actions CI/CD Pipeline

For team setups, run tests in GitHub Actions first before executing a remote deployment script over SSH.

Create .github/workflows/deploy.yml in your repository:

YAML
name: Production Deployment Pipeline

on:
  push:
    branches: [ main ]

jobs:
  test-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Run Unit Tests
        run: |
          npm ci
          npm test --if-present

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SERVER_IP }}
          username: deployer
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/your-node-app
            git pull origin main
            npm ci --omit=dev
            pm2 reload ecosystem.config.js --env production

5. Securing Your Node.js App in Production

Securing the OS and setting up HTTPS is only half the battle. Your application code itself must be hardened against common Web exploits.

1. HTTP Security Headers with helmet

By default, Node.js HTTP responses omit security headers, leaving your app exposed to cross-site scripting (XSS), clickjacking, and MIME sniffing attacks.

Register helmet at the top of your Express middleware chain:

JAVASCRIPT
// npm install helmet
const express = require('express');
const helmet = require('helmet');
const app = express();

// Set security headers
app.use(helmet());

helmet configures 15 security headers out of the box, including Strict-Transport-Security (HSTS), X-Frame-Options: DENY, and X-Content-Type-Options: nosniff.

2. Hide X-Powered-By Header

Express broadcasts its identity by default (X-Powered-By: Express). Turn this off immediately:

JAVASCRIPT
app.disable('x-powered-by');

3. Prevent Injection Attacks (SQL & NoSQL)

Never concatenate raw user input into database queries.

  • SQL (Postgres, MySQL): Use parameterized queries or ORMs (Prisma, Knex, TypeORM).
  • NoSQL (MongoDB): Attackers can send JSON payload objects containing MongoDB query operators ({ "username": { "$gt": "" }, "password": { "$gt": "" } }) to bypass authentication checks entirely.

Sanitize user input using express-mongo-sanitize:

JAVASCRIPT
// npm install express-mongo-sanitize
const mongoSanitize = require('express-mongo-sanitize');

// Strip out keys containing '$' or '.' from req.body and req.query
app.use(mongoSanitize());

4. Configure CORS Properly

Restrict Cross-Origin Resource Sharing (CORS) so malicious third-party websites cannot query your API from users’ browsers:

JAVASCRIPT
// npm install cors
const cors = require('cors');

const allowedOrigins = ['https://yourdomain.com', 'https://admin.yourdomain.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Blocked by CORS policy'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

If using HTTP cookies for session tracking or JWT storage, enforce security flags:

JAVASCRIPT
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true, // Prevents client JS from reading the cookie (XSS protection)
    secure: true,   // Sends cookie over HTTPS connections only
    sameSite: 'strict', // Protects against Cross-Site Request Forgery (CSRF)
    maxAge: 86400000 // 24 hours
  }
}));

6. Scan Dependencies for Vulnerabilities

Open-source npm dependencies are the primary vector for supply-chain attacks.

  • Run npm audit regularly to catch vulnerable packages.
  • Add Snyk or Trivy to your CI/CD pipeline to flag pull requests containing high-severity CVEs:
    BASH
    npx snyk test
    

6. Production Logging: Doing It Right

In development, console.log() is your primary tool. In high-traffic production, it is a bottleneck. Under load, synchronous writes to stdout can block Node’s single main thread and stall user requests.

1. Why You Need Structured JSON Logging

Plain text strings (console.log("User 42 signed in")) are frustrating to search through when you have millions of lines.

Structured JSON logging outputs log lines as indexed JSON objects:
{"level":30,"time":1710000000000,"pid":4120,"userId":42,"msg":"User signed in successfully"}

Centralized log managers (Loki, Elasticsearch, Datadog) parse these objects, allowing you to instantly search or set alerts on specific fields like userId or statusCode.

2. High-Speed Asynchronous Logging with pino

Use Pino—it writes logs asynchronously without blocking the event loop and includes automatic redaction for sensitive fields:

JAVASCRIPT
// npm install pino pino-http
const logger = require('pino')({
  level: process.env.LOG_LEVEL || 'info',
  redact: {
    paths: ['req.headers.authorization', 'password', 'creditCard', 'token'],
    censor: '[REDACTED]'
  }
});

const httpLogger = require('pino-http')({ logger });
app.use(httpLogger);

// Example log statement
logger.info({ userId: 42, event: 'PASSWORD_RESET' }, 'Password reset email sent');

3. Log Level Guidelines

Use standard log levels to filter out noisy messages in production:

Level Name When to Use
10 trace Deep internal debugging (local development only).
20 debug Detailed troubleshooting info for specific modules.
30 info Standard operational events (server startup, background job completed).
40 warn Non-fatal anomalies (deprecated API call, high connection count).
50 error Errors that caused a specific user request to fail (DB timeout, failed payment).
60 fatal Unrecoverable failures forcing the app to shut down (missing .env keys).

4. Track Async Requests with Correlation IDs

Because Node processes multiple requests concurrently on one thread, log statements from different users interleave on stdout.

Use Node’s native AsyncLocalStorage to bind a unique request UUID to all log calls within an async execution chain:

JAVASCRIPT
const { AsyncLocalStorage } = require('async_hooks');
const { v4: uuidv4 } = require('uuid');

const asyncLocalStorage = new AsyncLocalStorage();

// Middleware generating a unique Correlation ID per request
app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] || uuidv4();
  res.setHeader('X-Request-ID', requestId);
  
  asyncLocalStorage.run(new Map([['requestId', requestId]]), () => {
    next();
  });
});

// Helper logger that automatically attaches the active requestId
function logInfo(msg, meta = {}) {
  const store = asyncLocalStorage.getStore();
  const requestId = store ? store.get('requestId') : 'N/A';
  logger.info({ requestId, ...meta }, msg);
}

7. Production Best Practices & Reliability

Address these reliability strategies to keep your application online and resilient:

1. Use pm2 reload for Zero-Downtime Deployments

Running pm2 restart node-api kills all app instances immediately, causing 2–5 seconds of downtime while the new process boots up.

Always use pm2 reload node-api. PM2 starts new workers running your updated code alongside old workers, waits for them to signal readiness, and then gracefully closes old workers one by one without dropping a single active HTTP connection.

2. Graceful Shutdown: Don’t Drop Active User Requests

When PM2 reloads or stops a process, it sends a SIGINT or SIGTERM signal. If your app ignores this, the process terminates immediately—dropping in-flight HTTP requests or database transactions mid-stream.

Intercept termination signals, stop accepting new connections, close database pools cleanly, and then exit:

JAVASCRIPT
const express = require('express');
const app = express();
const db = require('./db');

const server = app.listen(3000, () => {
  console.log('Server listening on port 3000');
  if (process.send) process.send('ready');
});

function cleanResources() {
  return new Promise((resolve, reject) => {
    if (db && typeof db.close === 'function') {
      db.close((err) => (err ? reject(err) : resolve()));
    } else if (db && typeof db.disconnect === 'function') {
      db.disconnect().then(resolve).catch(reject);
    } else {
      resolve();
    }
  });
}

function gracefulShutdown(signal) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);
  
  // Stop accepting new HTTP connections
  server.close(() => {
    console.log('HTTP server closed.');
    cleanResources()
      .then(() => {
        console.log('Database connections closed cleanly.');
        process.exit(0);
      })
      .catch((err) => {
        console.error('Error closing database connections:', err);
        process.exit(1);
      });
  });

  // Drop idle keep-alive connections immediately (Node 18.2.0+)
  if (typeof server.closeIdleConnections === 'function') {
    server.closeIdleConnections();
  }

  // Force shutdown after 10 seconds if connections hang
  setTimeout(() => {
    console.error('Forced shutdown due to timeout');
    process.exit(1);
  }, 10000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

3. Zero-Downtime Database Migrations (Expand-Contract Pattern)

Running database migrations inside a deployment script can break active application workers if a migration drops or renames columns while old code is still handling requests.

Follow the Expand-Contract (Parallel Change) pattern for safe schema changes:

CODE
                  Expand-Contract Zero-Downtime Migration Pattern
 [ Phase 1: Expand ]          [ Phase 2: Deploy Code ]         [ Phase 3: Contract ]
+---------------------+      +------------------------+      +---------------------+
| Add new column      | ---> | Deploy Node.js code    | ---> | Drop old column     |
| (Database accepts   |      | reading new column &   |      | (Database cleanup   |
| both old & new)     |      | writing both fields    |      | after full reload)  |
+---------------------+      +------------------------+      +---------------------+
  1. Phase 1 (Expand): Push a database migration that adds new columns or tables without deleting old fields.
  2. Phase 2 (Deploy Code): Deploy Node code that writes to both old and new columns, falling back gracefully if data is missing.
  3. Phase 3 (Contract): Once all old workers are reloaded and no code references old fields, run a cleanup migration removing the obsolete columns.

4. Crash Cleanly on Uncaught Exceptions

If your app throws an uncaught error or unhandled promise rejection, the process can enter an unreliable state (leaking memory or holding open locks). Log the error details and exit cleanly with code 1, allowing PM2 to start a fresh instance immediately:

JAVASCRIPT
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'CRITICAL: Uncaught Exception');
  process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({ reason, promise }, 'CRITICAL: Unhandled Promise Rejection');
  process.exit(1);
});

5. Limit Memory Usage and Raise File Descriptors

Hard Memory Ceilings

Node processes will consume RAM until the OS kills them. Configure PM2 to auto-restart workers exceeding a designated memory threshold:

BASH
pm2 start ecosystem.config.js --max-memory-restart 500M

Raise Linux Open File Limits

High-traffic Node servers handling thousands of concurrent WebSockets or HTTP requests can hit Linux’s default file descriptor limit (1024), triggering EMFILE: too many open files.

Increase file limits in /etc/security/limits.conf:

TEXT
deployer soft nofile 65535
deployer hard nofile 65535

6. Enable Gzip Compression in Nginx

Let Nginx handle response compression instead of spending CPU cycles in Node. Add these directives to /etc/nginx/nginx.conf inside the http block:

NGINX
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_proxied any;
gzip_min_length 1000;

7. File Uploads in Clustered Environments

In a clustered environment (PM2 cluster mode or container replicas), saving user uploads to local disk creates a split-brain bug: a file saved on Instance A yields a 404 Not Found when requested from Instance B.

Solution: Direct-to-Cloud Uploads via Presigned URLs

Have your frontend upload files directly to AWS S3 or Cloudflare R2 using temporary Presigned URLs:

JAVASCRIPT
// npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

app.post('/api/get-presigned-url', async (req, res) => {
  const { fileName, fileType } = req.body;
  const uniqueKey = `uploads/${Date.now()}-${fileName}`;
  
  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET_NAME,
    Key: uniqueKey,
    ContentType: fileType,
  });

  try {
    const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
    res.json({
      uploadUrl,
      fileUrl: `https://${process.env.AWS_S3_BUCKET_NAME}.s3.amazonaws.com/${uniqueKey}`
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to generate upload URL' });
  }
});

8. Scaling Node.js Applications

Scaling Node.js falls into two main strategies: vertical scaling (utilizing all CPU cores on one box) and horizontal scaling (adding multiple servers).

1. Vertical Scaling (Cluster Mode)

Node runs on one CPU core by default. Use PM2 Cluster Mode to spin up worker processes for every CPU core available on your VPS:

BASH
pm2 start ecosystem.config.js --env production

2. Horizontal Scaling (Load Balancing)

When traffic outgrows a single VPS, place a load balancer in front of multiple application servers:

CODE
                           [ Incoming Internet Traffic ]
                                         │
                                         ▼
                               [ Load Balancer (Nginx) ]
                                ╱        │        ╲
                              ╱          │          ╲
                            ▼            ▼            ▼
                     [ Server 1 ]   [ Server 2 ]   [ Server 3 ]
                      (Port 3000)    (Port 3000)    (Port 3000)

Configure Nginx as a load balancer using an upstream group:

NGINX
upstream node_app_cluster {
    least_conn; # Route traffic to the server with fewest active connections
    server 10.0.0.10:3000;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://node_app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

3. Keep Your Application Stateless

To scale horizontally across servers, your Node app must be completely stateless:

  • Session Storage: Move user session data out of process memory and into Redis using connect-redis:
    JAVASCRIPT
    // npm install redis connect-redis express-session
    const session = require('express-session');
    const RedisStore = require('connect-redis').default;
    const { createClient } = require('redis');
    
    const redisClient = createClient({ url: process.env.REDIS_URL });
    redisClient.connect().catch(console.error);
    
    app.use(session({
      store: new RedisStore({ client: redisClient, prefix: "sess:" }),
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: { secure: true, httpOnly: true }
    }));
    
  • Shared WebSockets: Use @socket.io/redis-adapter so WebSocket messages published on Server 1 reach clients connected to Server 2.

4. Database Connection Pools

Running multiple Node instances multiplies open database connections.

  • Keep connection pool limits conservative (e.g., 5–10 connections per worker process).
  • For large-scale PostgreSQL deployments, run PgBouncer in front of your database to multiplex and manage connection pools.

9. Monitoring & Observability

Deploying code is step one. Once live, you need visibility into application performance and runtime health:

1. Application Performance Monitoring (APM)

Track HTTP transaction latency, throughput, and slow database queries using APM tools like Datadog, New Relic, or open-source OpenTelemetry.

2. Prometheus & Grafana Metrics

Track Event Loop delay, Heap Memory usage, and active handles using prom-client:

JAVASCRIPT
// npm install prom-client express
const express = require('express');
const client = require('prom-client');
const app = express();

client.collectDefaultMetrics();

const httpRequestDurationSeconds = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.1, 0.3, 0.5, 1.0, 2.0]
});

app.use((req, res, next) => {
  const start = process.hrtime();
  res.on('finish', () => {
    const diff = process.hrtime(start);
    const duration = diff[0] + diff[1] / 1e9;
    
    // Prevent high-cardinality metric explosion on unmapped routes
    const route = req.route ? req.route.path : 'unmatched_route';
    httpRequestDurationSeconds
      .labels(req.method, route, String(res.statusCode))
      .observe(duration);
  });
  next();
});

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', client.register.contentType);
  res.send(await client.register.metrics());
});

3. Error Tracking with Sentry

Catch unhandled exceptions and stack traces in real time before users report them:

JAVASCRIPT
// npm install @sentry/node
const Sentry = require("@sentry/node");

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 1.0,
});

app.use(Sentry.Handlers.errorHandler());

10. Troubleshooting Common Production Crashes

When deploying Node.js apps, you will hit runtime errors. Use this troubleshooting guide to quickly identify and fix common production failures:

Error / Symptom What Happened How to Fix It
Error: listen EADDRINUSE :::3000 Another process is already bound to port 3000 (often a zombie Node process). Find and terminate the process: lsof -i :3000 followed by kill -9 <PID>, or run fuser -k 3000/tcp.
Nginx 502 Bad Gateway Nginx cannot connect to Node. The Node app is down or listening on a different port. Run pm2 status to check if Node is online. Ensure Express listens on 127.0.0.1 and matches Nginx proxy_pass http://127.0.0.1:3000.
Nginx 504 Gateway Timeout Node took longer to respond than Nginx’s proxy timeout (default 60s). Check for CPU-heavy tasks blocking the event loop or slow DB queries lacking indexes. Increase proxy_read_timeout 120s; in Nginx if needed.
FATAL ERROR: Reached heap limit Allocation failed V8 memory limit exceeded due to a memory leak or processing large payloads. Raise memory limit: node --max-old-space-size=4096 index.js or set max_memory_restart: '1G' in PM2. Profile memory usage to fix leaks.
Error: ERR_HTTP_HEADERS_SENT Code attempted to send a response multiple times for one request (missing return). Audit handlers to ensure return is called after invoking res.send(), res.json(), or res.redirect().
ENOSPC: no space left on device Disk is 100% full, usually caused by unrotated log files. Install pm2-logrotate to compress logs automatically. Clear disk space: pm2 flush and sudo apt autoremove.
CORS Error: No 'Access-Control-Allow-Origin' header Browser blocked cross-origin request because backend CORS headers are missing. Use the cors package in Express and whitelist the frontend domain (origin: 'https://yourdomain.com').
Invalid ELF Header inside Docker Native C++ binaries compiled for macOS/Windows were copied into a Linux container. Add node_modules to .dockerignore. Always run npm ci inside the target container environment.

11. Alternative: Instant Deployment with SiliconPin Pods

If managing systemd services, Nginx configs, and server clusters involves too much operational overhead, you can deploy using SiliconPin Pods.

SiliconPin Pods run in rootless Linux network namespaces with automated SSL routing and built-in database sidecars (MariaDB, MongoDB, Valkey):

BASH
# 1. Install CLI and authenticate
curl -fsSL https://siliconpin.com/downloads/sp/install.sh | bash
sp login --token=$SP_TOKEN

# 2. Deploy your app from local directory or Git repo
sp deploy ./ --port=3000

# 3. Attach a database sidecar (MariaDB, MongoDB, Valkey)
sp deployments attach db mariadb <deployment-id>

12. Frequently Asked Questions (FAQ)

Q: How should I manage secret keys in production?

A: Never check .env files into Git repositories. Store .env on your production server with restricted read permissions (chmod 600 .env). In team environments, use centralized secret managers like Infisical, Doppler, or AWS Secrets Manager to inject environment variables into process managers at startup.

Q: Why shouldn’t I use console.log in high-traffic Node apps?

A: In Node.js, synchronous writes to stdout can block the single-threaded event loop when OS buffers fill up under heavy load. Use asynchronous structured loggers like Pino or Winston that format log output as JSON without blocking incoming user requests.

Q: How should I run database migrations in production?

A: Execute migrations (npx prisma migrate deploy or knex migrate:latest) as a pre-deployment step before reloading PM2 workers. Use the Expand-Contract strategy so existing workers don’t crash mid-migration. Never run migrations automatically from all PM2 cluster instances on startup, as concurrent migrations will lock database tables.

Q: How do I update environment variables in PM2 without downtime?

A: Update the .env file on your server, then run pm2 reload ecosystem.config.js --env production. pm2 reload restarts cluster instances sequentially, applying new environment variables while keeping the API online.

Q: What is the difference between pm2 reload and pm2 restart?

A: pm2 restart terminates all application processes at once, causing 2–5 seconds of downtime. pm2 reload boots up new workers in parallel, waits until they signal readiness, and then gracefully closes old workers without dropping active connections.

Q: Should I compile TypeScript or run code bundlers on the production server?

A: No. Compiling TypeScript (tsc) or bundling (webpack, vite) consumes heavy CPU and memory, causing response lag or out-of-memory crashes for active users. Always compile code locally or inside CI/CD (e.g. GitHub Actions), and deploy only the compiled dist/ JavaScript output.

Q: Why does my Node.js Docker container crash with “Invalid ELF Header”?

A: This happens if your local node_modules folder (containing C++ native binaries compiled for macOS or Windows) is copied into a Linux container. Always add node_modules to your .dockerignore file and allow npm ci --omit=dev to build clean Linux dependencies inside the container.

Tags: #deployment #node.js #nginx
t

[email protected]

Engineering, systems programming, and curated technology insights.