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
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.
[ 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:
# 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:
# 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.
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.
IMPORTANT1. Set
NODE_ENV=productioninside.env.
Frameworks like Express disable verbose debug logging, cache templates in memory, and optimize code paths whenNODE_ENVis set toproduction—boosting throughput by up to 3x.2. Restrict
.envpermissions.
Lock down your environment file so other unprivileged system users cannot read your secret keys:BASHchmod 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:
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:
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:
# 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:
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.
sudo apt install nginx -y
sudo nano /etc/nginx/sites-available/yourdomain.com
Paste in this server block:
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:
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
TIPEnable
app.set('trust proxy', true)in Express.
Because Nginx sits between the user and Node, Express sees all incoming connections as originating from127.0.0.1. Settingtrust proxyinstructs Express to read theX-Forwarded-Forheader populated by Nginx soreq.ipreturns 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:
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:
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:
yourdomain.com {
reverse_proxy 127.0.0.1:3000
}
Restart Caddy:
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:
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:
# 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:
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
NOTEBinding to
127.0.0.1:3000:3000keeps your container hidden behind your host’s Nginx/Caddy server, which acts as the public SSL gateway.
3. Build and Run Container
# 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
# 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):
#!/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
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:
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:
// 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:
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:
// 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:
// 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'],
}));
5. Enforce Secure Cookie Flags
If using HTTP cookies for session tracking or JWT storage, enforce security flags:
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 auditregularly 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:
// 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:
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:
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:
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) |
+---------------------+ +------------------------+ +---------------------+
- Phase 1 (Expand): Push a database migration that adds new columns or tables without deleting old fields.
- Phase 2 (Deploy Code): Deploy Node code that writes to both old and new columns, falling back gracefully if data is missing.
- 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:
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:
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:
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:
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:
// 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:
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:
[ 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:
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-adapterso 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:
// 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:
// 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):
# 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.
[email protected]
Engineering, systems programming, and curated technology insights.