Process Management with PM2
In non-containerized production environments (bare-metal, VMs, VPS), a Node.js process crashing on an unhandled exception will terminate the entire service. PM2 is an enterprise-grade Production Process Manager that provides automatic restarts, built-in cluster mode without code modifications, log rotation, zero-downtime reloads, and system startup hooks.
Ecosystem Configuration (ecosystem.config.cjs)
Avoid running raw CLI commands in production. Declare execution topology via an ecosystem manifest:
JavaScript
module.exports = {
apps: [
{
name: 'api-service',
script: './dist/server.js',
instances: 'max', // Scales across all available CPU cores automatically
exec_mode: 'cluster', // Utilizes Node.js native cluster module under the hood
watch: false, // Never enable watch in production
max_memory_restart: '1G', // Kills and re-spawns worker if it leaks over 1GB RAM
autorestart: true,
exp_backoff_restart_delay: 100,// Prevents restart thrashing if crashing on boot
env_production: {
NODE_ENV: 'production',
PORT: 3000
},
// Centralized PM2 Log aggregation
error_file: './logs/pm2-err.log',
out_file: './logs/pm2-out.log',
merge_logs: true,
time: true
}
]
};
Core PM2 Commands
Bash
# Start or reload via ecosystem configuration
pm2 start ecosystem.config.cjs --env production
# Zero-downtime rolling reload (workers reboot sequentially)
pm2 reload api-service
# Real-time resource dashboard (CPU, Memory, Event Loop latency)
pm2 monit
# Save current running process list to survive OS reboots
pm2 save
pm2 startup systemd # Generates and registers systemd boot script
Containerization: Production Docker Patterns
Running Node.js securely in Docker requires mitigating common vulnerabilities: running as root, leaking build secrets, bloating image sizes with dev dependencies, and failing to handle OS signals (SIGINT/SIGTERM).
1. Multi-Stage Production Dockerfile
This multi-stage Dockerfile uses a lean Alpine base, isolates native build dependencies, strips development tooling, and drops root privileges.
Dockerfile
# ---------------------------------------------------------------------
# Stage 1: Build Dependencies & Source Compilation
# ---------------------------------------------------------------------
FROM node:22-alpine AS builder
WORKDIR /usr/src/app
# Install native dependencies required for node-gyp, bcrypt, or canvas
RUN apk add --no-cache python3 make g++
# Copy package manifests first to leverage Docker layer caching
COPY package*.json ./
# Install ALL dependencies (including devDependencies like TypeScript)
RUN npm ci
# Copy source and build artifacts
COPY . .
RUN npm run build
# Prune devDependencies to keep only production packages
RUN npm prune --omit=dev
# ---------------------------------------------------------------------
# Stage 2: Minimal Production Runtime
# ---------------------------------------------------------------------
FROM node:22-alpine AS runner
# Tini acts as PID 1 to handle signal forwarding (SIGTERM) and reap zombie processes
RUN apk add --no-cache tini
WORKDIR /usr/src/app
ENV NODE_ENV=production
ENV PORT=3000
# Security: Never run containers as root
# 'node' user is built directly into official Node Alpine images (UID/GID 1000)
USER node
# Copy production dependencies and compiled code with non-root ownership
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/dist ./dist
EXPOSE 3000
# Wrap execution with tini init process
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
2. Multi-Container Orchestration (docker-compose.yml)
A self-contained production-mirror environment linking the Node app, a persistent PostgreSQL database, and a Redis caching layer with health dependencies and resource constraints.
YAML
services:
app:
build:
context: .
target: runner
container_name: node_api
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://app_user:strong_password@postgres:5432/app_db?schema=public
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: '1.5'
memory: 1024M
networks:
- internal_network
postgres:
image: postgres:16-alpine
container_name: postgres_db
restart: unless-stopped
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: strong_password
POSTGRES_DB: app_db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d app_db"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal_network
redis:
image: redis:7-alpine
container_name: redis_cache
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
- internal_network
volumes:
pgdata:
redisdata:
networks:
internal_network:
driver: bridge
Logging & Observability
1. Structured Logging with Pino
Standard console.log() executes synchronously when writing to stdout in terminal configurations, which can block the event loop. Pino is an asynchronous, high-throughput JSON logger designed to produce machine-parseable log lines for aggregation tools like Datadog, Grafana Loki, or AWS CloudWatch.
Bash
npm install pino pino-http
JavaScript
// src/utils/logger.js
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Redact sensitive payload properties automatically to protect PII
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'password', 'token', '*.creditCard'],
censor: '[REDACTED]'
},
base: {
env: process.env.NODE_ENV,
pid: process.pid
},
timestamp: pino.stdTimeFunctions.isoTime
});
JavaScript
// Register HTTP Request Logging Middleware (src/app.js)
import express from 'express';
import { pinoHttp } from 'pino-http';
import crypto from 'node:crypto';
import { logger } from './utils/logger.js';
const app = express();
app.use(
pinoHttp({
logger,
genReqId: (req) => req.headers['x-request-id'] || crypto.randomUUID(),
customLogLevel: (req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
}
})
);
2. Health-Check Endpoints: Liveness vs. Readiness
Container orchestrators (Kubernetes, AWS ECS) use probes to evaluate container state:
- Liveness Probe (
/health/live): Is the Node process running? If this fails, the orchestrator immediately kills and restarts the container. - Readiness Probe (
/health/ready): Is the application able to handle incoming traffic? Checks database connections, Redis caches, and message brokers. If this fails, traffic routing pauses without restarting the container.
JavaScript
// src/routes/health.routes.js
import { Router } from 'express';
import { prisma } from '../config/db.js';
import { redis } from '../config/redis.js';
const router = Router();
// Liveness Probe
router.get('/health/live', (req, res) => {
res.status(200).json({ status: 'live', uptime: process.uptime() });
});
// Readiness Probe
router.get('/health/ready', async (req, res) => {
const checks = {
database: false,
redis: false
};
try {
// 1. Verify DB connection
await prisma.$queryRaw`SELECT 1`;
checks.database = true;
// 2. Verify Redis cache connection
const redisPing = await redis.ping();
checks.redis = (redisPing === 'PONG');
const isHealthy = Object.values(checks).every(Boolean);
res.status(isHealthy ? 200 : 503).json({
status: isHealthy ? 'ready' : 'unhealthy',
timestamp: new Date().toISOString(),
dependencies: checks
});
} catch (err) {
req.log.error({ err }, 'Readiness check failed');
res.status(503).json({
status: 'unhealthy',
dependencies: checks
});
}
});
export default router;
3. Graceful Shutdown Signal Handling
When an orchestrator scales down or deploys a new container revision, it sends a SIGTERM signal. The server must close active TCP listeners, finish in-flight requests, close database connection pools, and exit cleanly.
JavaScript
// src/server.js
import { app } from './app.js';
import { prisma } from './config/db.js';
import { redis } from './config/redis.js';
import { logger } from './utils/logger.js';
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server operational on port ${PORT}`);
});
function gracefulShutdown(signal) {
logger.info(`Received ${signal}. Starting graceful shutdown sequence...`);
// 1. Stop accepting new connections
server.close(async () => {
logger.info('HTTP server closed. Disconnecting persistent client pools...');
try {
// 2. Drain and disconnect database and Redis clients
await prisma.$disconnect();
await redis.quit();
logger.info('Database & Cache connections closed safely.');
process.exit(0);
} catch (err) {
logger.error({ err }, 'Error during resource teardown');
process.exit(1);
}
});
// Force kill if requests do not clear within a 15-second grace period
setTimeout(() => {
logger.error('Forced shutdown: Timed out waiting for lingering connections.');
process.exit(1);
}, 15000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Deployment Targets & Architectures
| Deployment Target | Paradigm | Infrastructure Model | Best Suited For | Tradeoffs |
|---|---|---|---|---|
| AWS ECS (Fargate) | Containerized | Serverless Containers | Production microservices, sustained traffic | Requires VPC, ALB, IAM setup |
| AWS Lambda | Serverless | Function-as-a-Service | Sporadic APIs, webhooks, cron jobs | Cold starts, 15m run limit, connection limits |
| AWS EC2 | Virtual Machine | Raw Compute (IaaS) | Custom OS needs, legacy monoliths | Manual OS patching, scaling, security groups |
| DigitalOcean (App Platform/Droplets) | PaaS / VPS | Managed runtime or VM | Small/Mid teams, cost-efficient scaling | Limited custom low-level networking |
| Render / Railway | PaaS | Git-driven Deployments | Startups, staging, fast prototyping | Bandwidth/compute costs scale steeper at volume |
CI/CD Pipeline: GitHub Actions
A production CI/CD pipeline validates code quality, runs test suites with real services, builds an optimized Docker image, and triggers an automated rollout.
Create .github/workflows/deploy.yml:
YAML
name: Continuous Integration & Deployment
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate-and-test:
name: Code Quality & Automated Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Setup Node.js Runtime
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Static Code Analysis (Linter & Type Check)
run: |
npm run lint --if-present
npm run typecheck --if-present
- name: Execute Automated Test Suites
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
JWT_ACCESS_SECRET: github_actions_dummy_secret_key_32bytes_long
run: npm run test:coverage
build-and-ship:
name: Build Multi-Stage Docker Image
needs: validate-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Authenticate to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
target: runner
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Deployment dispatch step (e.g., Render, Railway, AWS ECS task update)
- name: Trigger Remote Deployment Webhook
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK }}
run: |
if [ -n "$DEPLOY_WEBHOOK_URL" ]; then
curl -f -X POST "$DEPLOY_WEBHOOK_URL"
fi
Recommended Portfolio Projects
- REST API with Full Auth & RBAC: Multi-tenant blog or e-commerce API with JWTs, refresh tokens, role checks, and pagination.
- Real-Time Collaborative App: Chat application or collaborative document editor with Socket.io and Redis adapters.
- High-Throughput File Processor: Video/image transcoding queue or CSV batch importer using streams and background worker queues.
