Node.js: Deploy and Optimize
Last updated: 2026-08-26
After Charlie deployed the API to the production server using node app.js, the process would frequently crash due to uncaught exceptions, causing prolonged service outages during unattended operation. He implemented PM2 to enable automatic restart and cluster mode, combined with Docker containerization and an Nginx reverse proxy, which boosted service availability from 95% to 99.9%—and he was finally able to sleep soundly.
You'll learn:
- PM2 Process Management (start / restart / logs / monit)
- Docker Basics (Dockerfile / docker-compose)
- Environment Variable Management (.env / Production Configuration)
- Cluster module
- Performance Analysis (console.time / performance hooks / clinic.js)
- Design of Health Check Endpoints
- Nginx Reverse Proxy Configuration
1. PM2 Process Management
(1) Why Do We Need a Process Manager?
In a production environment, Node.js processes may crash due to uncaught exceptions, memory leaks, or insufficient system resources. If started directly with node app.js, the process will not be restarted after a crash, resulting in service interruption. PM2 is the most popular process manager for Node.js, offering automatic restart, log management, load balancing, and monitoring features.
▶ Example: (2) Installation and Basic Commands
npm install -g pm2
pm2 start app.js --name "my-api"
pm2 restart my-api
pm2 stop my-api
pm2 delete my-api
pm2 logs my-api
pm2 monit
(3) Quick Reference for Common PM2 Commands
| Command | Function | Common Use Cases |
|---|---|---|
pm2 start app.js |
Launch the App | First Deployment |
pm2 restart <name> |
Restart the app | After updating the code |
pm2 reload <name> |
Zero-Downtime Restart | Production Environment Update |
pm2 stop <name> |
Service Suspended | During Maintenance |
pm2 delete <name> |
Delete Process | Completely Remove |
pm2 logs [name] |
View Log | Troubleshoot |
pm2 monit |
Real-time Monitoring Dashboard | Monitor Resource Usage |
pm2 list |
Process List | View Running Status |
pm2 describe <name> |
Process Details | In-Depth Diagnostics |
pm2 save |
Save Process List | Startup Configuration |
pm2 startup |
Generate a startup script | Automatically restore after server restart |
▶ Example: PM2 Startup and Monitoring
pm2 start app.js --name "charlie-api" -i max
pm2 monit
pm2 save
pm2 startup
┌─────┬──────────────┬─────────────┬─────────┬─────────┬──────────┐
│ id │ name │ mode │ ↺ │ status │ cpu │
├─────┼──────────────┼─────────────┼─────────┼─────────┼──────────┤
│ 0 │ charlie-api │ cluster │ 15 │ online │ 12% │
│ 1 │ charlie-api │ cluster │ 2 │ online │ 8% │
│ 2 │ charlie-api │ cluster │ 0 │ online │ 5% │
│ 3 │ charlie-api │ cluster │ 1 │ online │ 3% │
└─────┴──────────────┴─────────────┴─────────┴─────────┴──────────┘
2. Docker Basics
(1) Why Use Docker
Docker packages applications and their dependencies into container images, ensuring consistency across development, testing, and production environments. Charlie’s code, which had previously run successfully on his local machine, threw errors after being deployed to the server due to differences in Node.js versions, but Docker completely resolved this issue.
▶ Example: (2) Writing a Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD node healthcheck.js
USER node
CMD ["node", "app.js"]
▶ Example: (3) Docker Compose Multi-Service Orchestration
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://mongo:27017/myapp
- REDIS_URL=redis://redis:6379
depends_on:
- mongo
- redis
restart: always
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 30s
timeout: 3s
retries: 3
mongo:
image: mongo:7
volumes:
- mongo-data:/data/db
restart: always
redis:
image: redis:7-alpine
restart: always
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
restart: always
volumes:
mongo-data:
(4) Comparison of Deployment Methods
| Dimension | Bare-metal deployment | Docker deployment | Cloud service deployment |
|---|---|---|---|
| Environmental Consistency | Poor | Excellent | Excellent |
| Deployment Speed | Slow | Fast | Fastest |
| Operations Complexity | High | Medium | Low |
| Resource Utilization | High | Medium | On-Demand |
| Expansion Flexibility | Poor | Good | Excellent |
| Cost | Low | Medium | High |
| Use Cases | Small Projects | Medium and Large Projects | Enterprise-Level Projects |
| Learning Curve | Low | Medium | High |
▶ Example: Building and Running a Docker Image
docker build -t charlie-api:1.0 .
docker run -d -p 3000:3000 --env-file .env charlie-api:1.0
docker-compose up -d
docker-compose logs -f api
3. Environment Variable Management
(1) Why Are Environment Variables Needed?
Configuration settings such as database addresses, port numbers, and passwords vary across different environments (development, testing, and production). Hard-coding these settings creates security risks and makes it difficult to switch between environments. Environment variables separate configuration from code, in line with the Twelve-Factor App methodology.
▶ Example: (2) dotenv and .env Files
const dotenv = require('dotenv');
dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}` });
const config = {
port: parseInt(process.env.PORT, 10) || 3000,
mongoUri: process.env.MONGO_URI,
jwtSecret: process.env.JWT_SECRET,
redisUrl: process.env.REDIS_URL,
logLevel: process.env.LOG_LEVEL || 'info',
};
module.exports = config;
▶ Example: (3) Production Environment Configuration Strategy
.env # Default Configuration(Do not submit Git)
.env.development # Development Environment
.env.test # Test Environment
.env.production # Production Environment(Through CI/CD Inject,Do not commit to the code repository)
(4) Comparison of Environment Variable Management Methods
| Method | Security | Flexibility | Team Collaboration | Use Cases |
|---|---|---|---|---|
.env File |
Medium | High | Good | General Projects |
| System Environment Variables | High | Low | Poor | Easy Deployment |
| Docker secrets | High | Medium | Good | Docker environment |
| K8s Secrets | High | High | Excellent | Kubernetes |
| Cloud Platform Configuration Center | High | High | Excellent | Cloud-Native Projects |
▶ Example: Loading Multi-Environment Configurations
const path = require('path');
const dotenv = require('dotenv');
const env = process.env.NODE_ENV || 'development';
const envFile = path.resolve(process.cwd(), `.env.${env}`);
dotenv.config({ path: envFile });
if (env === 'development') {
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
}
const required = ['MONGO_URI', 'JWT_SECRET'];
const missing = required.filter(key => !process.env[key]);
if (missing.length) {
throw new Error(`Missing required env vars: ${missing.join(', ')}`);
}
module.exports = {
env,
port: parseInt(process.env.PORT, 10) || 3000,
mongoUri: process.env.MONGO_URI,
jwtSecret: process.env.JWT_SECRET,
redisUrl: process.env.REDIS_URL,
logLevel: process.env.LOG_LEVEL || 'info',
};
4. Cluster Mode
(1) Why Do We Need Clusters?
Node.js is single-threaded, meaning only one instance can run on a single CPU core. Cluster mode uses the cluster module to create multiple worker processes, fully utilizing multi-core CPUs to significantly improve throughput and availability.
(2) How the cluster Module Works
The master process is responsible for listening on the port and distributing requests, while the worker processes handle the actual business logic. Multiple worker processes share the same port to achieve load balancing.
▶ Example: (3) Manually Setting Up a Cluster
const cluster = require('cluster');
const os = require('os');
const http = require('http');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Master ${process.pid} is running`);
console.log(`Forking ${numCPUs} workers...`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Handled by worker ${process.pid}\n`);
}).listen(3000);
console.log(`Worker ${process.pid} started`);
}
(4) PM2 Cluster Mode
PM2 has built-in cluster support that can be enabled without modifying the code:
pm2 start app.js -i max
pm2 start app.js -i 4
▶ Example: Performance Comparison of Cluster Modes
node single.js &
ab -n 10000 -c 100 http://localhost:3000/
Single process:
Requests per second: 3254.21 [#/sec]
Cluster (4 workers):
Requests per second: 11280.67 [#/sec]
5. Performance Analysis
(1) Basic Timing with console.time
The simplest way to measure performance, ideal for quickly identifying slow operations:
app.get('/api/users', async (req, res) => {
console.time('fetch-users');
const users = await User.find().lean();
console.timeEnd('fetch-users');
res.json(users);
});
(2) Performance Hooks: Precise Measurement
The built-in perf_hooks module in Node.js provides high-precision timing:
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});
});
obs.observe({ type: 'measure', buffered: true });
function measureAsync(label, fn) {
return async (...args) => {
performance.mark(`${label}-start`);
const result = await fn(...args);
performance.mark(`${label}-end`);
performance.measure(label, `${label}-start`, `${label}-end`);
return result;
};
}
const fastQuery = measureAsync('db-query', async () => {
return await User.find().lean();
});
(3) Clinic.js Professional Diagnosis
Clinic.js is the official performance diagnostic toolkit recommended by Node.js:
npm install -g clinic
clinic doctor -- node app.js
clinic flame -- node app.js
clinic bubbleprof -- node app.js
(4) Comparison of Performance Optimization Strategies
| Strategy | Tools/Methods | Applicable Scenarios | Difficulty | Effectiveness |
|---|---|---|---|---|
| Timing Analysis | console.time | Quick Locate | Low | Medium |
| Accurate Measurement | perf_hooks | Critical Path | Medium | High |
| CPU Analysis | Clinic Flame | CPU-Intensive | Medium | High |
| Event Loop | Clinic Doctor | I/O Blocking | Medium | High |
| Memory Leak | heapdump / memwatch | Memory Issues | High | High |
| Load Testing | autocannon / ab | Capacity Planning | Low | Medium |
| APM Monitoring | New Relic / Datadog | Continuous Monitoring | Medium | High |
▶ Example: Using autocannon for load testing
npm install -g autocannon
autocannon -c 100 -d 10 http://localhost:3000/api/health
┌─────────┬──────┬──────┬───────┬──────┬───────┬───────┬───────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼──────┼──────┼───────┼──────┼───────┼───────┼───────┤
│ Latency │ 2 ms │ 4 ms │ 12 ms │ 18ms │ 5 ms │ 3 ms │ 45 ms │
└─────────┴──────┴──────┴───────┴──────┴───────┴───────┴───────┘
Requests/sec: 18523.6
6. Health Check Endpoints
(1) Why Are Health Checkups Necessary?
Container orchestration systems (Docker, K8s) and load balancers need to know whether a service is running normally. Health check endpoints provide a standardized interface for detecting service status, enabling automatic fault detection and traffic switching.
▶ Example: (2) Basic Health Examination
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
▶ Example: (3) In-Depth Health Check
app.get('/health', async (req, res) => {
const checks = {
server: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
};
try {
await mongoose.connection.db.admin().ping();
checks.database = 'ok';
} catch (err) {
checks.database = 'error';
}
try {
await redisClient.ping();
checks.redis = 'ok';
} catch (err) {
checks.redis = 'error';
}
const isHealthy = checks.database === 'ok' && checks.redis === 'ok';
res.status(isHealthy ? 200 : 503).json(checks);
});
(4) Separation of readiness and liveness
In production environments, health checks are typically divided into two types: liveness (whether the process is alive) and readiness (whether it is ready to receive traffic):
app.get('/healthz', (req, res) => {
res.json({ status: 'alive' });
});
app.get('/readyz', async (req, res) => {
try {
await mongoose.connection.db.admin().ping();
await redisClient.ping();
res.json({ status: 'ready' });
} catch {
res.status(503).json({ status: 'not ready' });
}
});
▶ Example: Docker Health Check Script
const http = require('http');
const options = {
hostname: 'localhost',
port: process.env.PORT || 3000,
path: '/healthz',
timeout: 2000,
};
const req = http.request(options, (res) => {
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
req.on('error', () => process.exit(1));
req.on('timeout', () => { req.destroy(); process.exit(1); });
req.end();
7. Nginx Reverse Proxy
(1) Why Do We Need Nginx?
Nginx acts as a reverse proxy, providing features such as SSL termination, load balancing, static resource serving, and request throttling. Node.js focuses on business logic, while Nginx handles network-layer optimization; each performs its own specific role.
▶ Example: (2) Node.js Production Deployment Architecture
graph LR
Client[Client] --> Nginx[Nginx Reverse Proxy<br/>:80/:443]
Nginx --> PM2[PM2 Cluster Management]
PM2 --> W1[Worker 1<br/>:3000]
PM2 --> W2[Worker 2<br/>:3000]
PM2 --> W3[Worker 3<br/>:3000]
PM2 --> W4[Worker 4<br/>:3000]
W1 --> DB[(MongoDB)]
W2 --> DB
W3 --> Redis[(Redis)]
W4 --> Redis
(3) Nginx configuration
upstream nodejs_backend {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/ssl/certs/api.example.com.crt;
ssl_certificate_key /etc/ssl/certs/api.example.com.key;
location / {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /health {
proxy_pass http://nodejs_backend/health;
access_log off;
}
location /static/ {
alias /app/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req zone=api burst=50 nodelay;
}
▶ Example: Nginx Reverse Proxy Configuration in Docker
# docker-compose.yml Excerpt
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/ssl/certs:ro
depends_on:
api:
condition: service_healthy
restart: always
8. Comprehensive Example: Complete Deployment Configuration
Charlie consolidated all deployment configurations into a single system, automating the entire process from code to production.
▶ Example: Project Directory Structure
charlie-api/
├── app.js
├── healthcheck.js
├── config/
│ └── index.js
├── ecosystem.config.js
├── Dockerfile
├── docker-compose.yml
├── nginx.conf
├── .env.example
├── .dockerignore
└── package.json
▶ Example: PM2 ecosystem.config.js
module.exports = {
apps: [
{
name: 'charlie-api',
script: 'app.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '512M',
env_development: {
NODE_ENV: 'development',
PORT: 3000,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
error_file: './logs/error.log',
out_file: './logs/out.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm:ss',
max_restarts: 10,
restart_delay: 4000,
kill_timeout: 5000,
listen_timeout: 10000,
},
],
deploy: {
production: {
user: 'deploy',
host: 'api.example.com',
ref: 'origin/main',
repo: 'git@github.com:charlie/api.git',
path: '/var/www/charlie-api',
'pre-deploy-local': '',
'post-deploy':
'npm ci && pm2 reload ecosystem.config.js --env production',
'pre-setup': '',
},
},
};
▶ Example: Dockerfile (Optimized for Multi-Stage Builds)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build 2>/dev/null || true
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app ./
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node healthcheck.js
CMD ["node", "app.js"]
▶ Example: .env.example
NODE_ENV=production
PORT=3000
MONGO_URI=mongodb://mongo:27017/charlie-api
REDIS_URL=redis://redis:6379
JWT_SECRET=your-secret-key-here
LOG_LEVEL=info
CORS_ORIGIN=https://example.com
RATE_LIMIT_WINDOW=60000
RATE_LIMIT_MAX=100
▶ Example: healthcheck.js
const http = require('http');
const req = http.request(
{
hostname: '127.0.0.1',
port: parseInt(process.env.PORT, 10) || 3000,
path: '/healthz',
timeout: 2000,
},
(res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
}
);
req.on('error', () => process.exit(1));
req.on('timeout', () => { req.destroy(); process.exit(1); });
req.end();
▶ Example: Health check endpoint in app.js
const express = require('express');
const mongoose = require('mongoose');
const config = require('./config');
const app = express();
app.use(express.json());
app.get('/healthz', (req, res) => {
res.json({ status: 'alive', pid: process.pid });
});
app.get('/readyz', async (req, res) => {
try {
await mongoose.connection.db.admin().ping();
res.json({ status: 'ready', pid: process.pid, uptime: process.uptime() });
} catch {
res.status(503).json({ status: 'not ready', pid: process.pid });
}
});
app.get('/health', async (req, res) => {
const checks = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
pid: process.pid,
};
try {
await mongoose.connection.db.admin().ping();
checks.database = 'ok';
} catch {
checks.database = 'error';
checks.status = 'degraded';
}
const isHealthy = checks.database === 'ok';
res.status(isHealthy ? 200 : 503).json(checks);
});
app.get('/api/users', async (req, res) => {
console.time('fetch-users');
const users = await mongoose.model('User').find().lean();
console.timeEnd('fetch-users');
res.json({ success: true, data: users });
});
mongoose.connect(config.mongoUri).then(() => {
app.listen(config.port, () => {
console.log(`Server running on port ${config.port} [${config.env}]`);
});
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
mongoose.connection.close();
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully...');
mongoose.connection.close();
process.exit(0);
});
❓ FAQ
Q: Which should I use, PM2 or Docker? A: The two are not mutually exclusive. Docker addresses environment consistency issues, while PM2 handles process management and clustering. For production environments, we recommend using Docker and PM2 together, with PM2 running inside a Docker container to manage multiple processes.
Q: How can I achieve zero-downtime deployment? A: Replace pm2 restart with pm2 reload. PM2 will restart the Workers one by one, ensuring that some instances remain online to handle requests at all times. In a Docker environment, you can use blue-green deployment or rolling update strategies.
Q: What are the limitations of cluster mode? A: In cluster mode, processes do not share memory; sessions must be shared using external storage such as Redis; WebSockets must be used in conjunction with sticky sessions or Pub/Sub message synchronization; and the file system cache is independent for each process.
Q: How do I monitor the production environment? A: PM2 offers pm2 monit real-time monitoring and pm2 plus an online dashboard; commercial solutions include New Relic, Datadog, and Prometheus + Grafana; key metrics include CPU, memory, event loop latency, and request response time.
Q: How can I reduce the size of a Docker image? A: Use the node:alpine base image, use multi-stage builds to separate compilation dependencies, .dockerignore exclude unnecessary files, npm ci --only=production install only production dependencies, and clear the npm cache npm cache clean --force.
Q: What are the advantages of using Nginx as a reverse proxy? A: SSL offloading reduces the encryption overhead on Node.js; static resources are handled directly by Nginx; load balancing distributes requests; rate limiting prevents DDoS attacks; gzip compression reduces data transfer volume; and caching speeds up responses.
📖 Summary
(1) Key Points of This Lesson
- PM2 implements automatic process restart, cluster mode, and log management
- Docker containerization ensures environmental consistency and standardized deployment
- Separate configuration via environment variables, with support for switching between multiple environments
- Cluster mode takes full advantage of multi-core CPUs to increase throughput
- Performance Hooks and Clinic.js: Pinpointing Performance Bottlenecks
- Health check endpoints are the foundation of container orchestration and load balancing
- Nginx Reverse Proxy for SSL, Load Balancing, and Static Resources
(2) Homework
- Start your Express project using PM2, configure
ecosystem.config.js, enable cluster mode, and usepm2 monitto monitor the running status. - Write a Dockerfile for the project, use multi-stage builds to optimize the image size, and use
docker-composeto start Node.js and MongoDB simultaneously - Implement the three health check endpoints
/health,/healthz, and/readyz, and configureHEALTHCHECKin Docker. - Use
autocannonto perform load testing on your API, comparing the throughput differences between single-process and cluster modes. - Write the Nginx reverse proxy configuration to implement SSL termination and load balancing, and use
aborcurlto verify that the proxy forwarding is working properly.
📝 Exercises
- Complete all the code examples in this lesson and make sure each one runs correctly.
- Modify the comprehensive example and add your own extensions
- Review the official documentation, identify 1–2 APIs not covered in this lesson, and write test code for them.
- Reflection: How would you apply what you’ve learned in this lesson to a real-world project?
- Try to combine what you’ve learned in this lesson with material from previous lessons to build a small project.