How to Deploy Node.js Applications to Production: PM2, Docker, Nginx & SSL
👤 tarun basu •
📅 August 12, 2026 •
👁️ 28 views
• 🔄 Updated August 12, 2026
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:
```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'],
}));
```
### 5. Enforce Secure Cookie Flags
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:
```
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:
```
[ 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.
Discussion
No comments yet. Be the first to start the discussion.