Home JavaScript Node.js Production Best Practices and Checklist
Advanced 7 min · 2026-07-12

Node.js Production Best Practices and Checklist

Node.js production checklist: environment configuration, logging, security headers, error handling, process management, monitoring, and deployment best practices..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 15-20 minutes
  • Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
 ● Production Incident
Quick Answer

Production-ready Node.js requires configuration across multiple dimensions: environment management (validated env vars, secrets management), logging (structured JSON, log levels, correlation IDs), sec

✦ Definition~90s read
What is Node.js Production Best Practices and Checklist?

Production-ready Node.js requires configuration across multiple dimensions: environment management (validated env vars, secrets management), logging (structured JSON, log levels, correlation IDs), security (Helmet headers, rate limiting, input validation, dependency auditing), error handling (global error handler, uncaught exception handler, unhandled rejection handler), process management (PM2 or container orchestration with restart policies), monitoring (health checks, metrics, distributed tracing), and deployment (zero-downtime, database migrations, feature flags). The production checklist is a living document that teams use for deployment readiness reviews and incident post-mortems.

Think of your Node.js app like a food truck.
Plain-English First

Think of your Node.js app like a food truck. You can cook fast, but if you don't have a checklist—like keeping ingredients cold, cleaning the grill, and having a backup generator—you'll serve bad food or shut down on a busy day. Production best practices are that checklist: they keep your app running smoothly under pressure.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

Your Node.js application passed all tests and code reviews. Two hours into production, the health check endpoint returns 503, logs are streaming to stdout with no log rotation, environment variables are hardcoded in the source code, and the database connection pool is exhausted because pool size was never configured. A production checklist transforms deployment from guesswork into a repeatable process. This article compiles every check you need before going live: security, performance, observability, reliability, and operational readiness.

1. Process Management and Clustering

In production, a single Node.js process is a single point of failure. Use a process manager like PM2 to run your app in cluster mode, spawning multiple instances across CPU cores. This ensures zero-downtime restarts, graceful shutdown, and automatic recovery from crashes. Configure instances: 'max' to utilize all cores. Set max_memory_restart to prevent memory leaks from taking down the server. Always use --max-old-space-size to limit V8 heap. Without clustering, a single uncaught exception or memory spike kills your entire service. PM2 also provides log management and monitoring hooks.

ecosystem.config.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
  apps: [{
    name: 'my-app',
    script: 'dist/server.js',
    instances: 'max',
    exec_mode: 'cluster',
    max_memory_restart: '500M',
    env: {
      NODE_ENV: 'production',
      NODE_OPTIONS: '--max-old-space-size=512'
    },
    kill_timeout: 5000,
    listen_timeout: 3000
  }]
};
Output
PM2 starts 4 instances (on 4-core CPU). Each instance uses up to 512MB heap. If one crashes, PM2 restarts it immediately without affecting others.
Try it live
⚠ Don't rely on built-in cluster module directly
The built-in cluster module lacks production features like graceful shutdown, log aggregation, and health checks. PM2 or similar tools are battle-tested.
📊 Production Insight
We once had a memory leak that brought down a single-process server every 6 hours. Clustering with PM2 and max_memory_restart auto-restarted workers, keeping the service alive while we fixed the leak.
🎯 Key Takeaway
Always run Node.js in cluster mode with a process manager to ensure high availability and resource utilization.
nodejs-production-checklist THECODEFORGE.IO Node.js Production Stack Layers Component hierarchy from load balancer to database Load Balancer Nginx | HAProxy | AWS ALB Process Manager PM2 Cluster | Kubernetes Pods Application Express Routes | Middleware | Error Handler Caching Redis | In-Memory Cache Database PostgreSQL | MongoDB | Connection Pool Monitoring Winston Logger | Prometheus Metrics | Health Checks THECODEFORGE.IO
thecodeforge.io
Nodejs Production Checklist

2. Environment Configuration Management

Never hardcode secrets or environment-specific values. Use environment variables with a library like dotenv for local development, but in production, inject variables via the deployment platform (e.g., Kubernetes secrets, AWS Parameter Store). Validate all required variables at startup using a schema (e.g., joi or env-var). This prevents silent failures when a config is missing. Also, separate config by environment: development, staging, production. Use a single source of truth like a config module that reads from process.env and exports typed values. Avoid using .env files in production — they can be accidentally committed.

config/index.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const envalid = require('envalid');
const { str, num, bool } = envalid;

const env = envalid.cleanEnv(process.env, {
  NODE_ENV: str({ choices: ['development', 'production', 'test'] }),
  PORT: num({ default: 3000 }),
  DB_URL: str(),
  REDIS_URL: str(),
  JWT_SECRET: str(),
  ENABLE_LOGGING: bool({ default: true })
});

module.exports = {
  port: env.PORT,
  dbUrl: env.DB_URL,
  redisUrl: env.REDIS_URL,
  jwtSecret: env.JWT_SECRET,
  isProduction: env.NODE_ENV === 'production',
  enableLogging: env.ENABLE_LOGGING
};
Output
If DB_URL is missing, the app throws a clear error at startup: "Missing environment variable: DB_URL".
Try it live
💡Use .env.example in repo
Commit a template .env.example with dummy values so new developers know what variables are needed. Never commit actual .env files.
📊 Production Insight
A missing database URL caused our staging environment to connect to production DB for 10 minutes. Validation would have caught it immediately.
🎯 Key Takeaway
Validate all environment variables at startup to fail fast and avoid runtime surprises.

3. Error Handling and Uncaught Exceptions

Node.js crashes on uncaught exceptions. Always use a global error handler for uncaught exceptions and unhandled promise rejections. However, these handlers should log the error and then gracefully shut down the process — the process is in an unknown state. Use a process manager to restart. For operational errors (e.g., invalid input), use a centralized error-handling middleware in Express. Return consistent JSON error responses with appropriate HTTP status codes. Never expose stack traces in production. Use libraries like http-errors to create error objects. Also, handle async errors by wrapping route handlers with a catch-all.

src/middleware/errorHandler.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const createError = require('http-errors');

// Async wrapper
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Centralized error handler
const errorHandler = (err, req, res, next) => {
  const statusCode = err.status || 500;
  const message = err.message || 'Internal Server Error';

  // Log full error
  console.error(`[${new Date().toISOString()}] ${err.stack}`);

  res.status(statusCode).json({
    error: {
      message: process.env.NODE_ENV === 'production' ? 'Something went wrong' : message,
      status: statusCode
    }
  });
};

// Global uncaught handlers
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  process.exit(1);
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled Rejection:', reason);
  process.exit(1);
});

module.exports = { asyncHandler, errorHandler };
Output
When a route throws an error, the client receives: {"error":{"message":"Something went wrong","status":500}}. Stack trace is logged server-side only.
Try it live
⚠ Don't swallow errors silently
Catching errors without logging or rethrowing leads to silent failures. Always log and then decide: recover or crash.
📊 Production Insight
An unhandled promise rejection in a payment callback caused silent data loss. Adding the global handler and crashing forced a restart and alerted us to the bug.
🎯 Key Takeaway
Handle all errors centrally, log them, and crash on uncaught exceptions — let the process manager restart.
nodejs-production-checklist THECODEFORGE.IO Node.js Production Stack Layers Component hierarchy for resilient application design Load Balancer Nginx | HAProxy Process Management PM2 Cluster | Node Cluster Module Application Core Express Routes | Middleware Pipeline Data Access Connection Pool | ORM/ODM Caching Layer Redis | In-Memory Cache Monitoring & Logging Winston | Prometheus | Grafana THECODEFORGE.IO
thecodeforge.io
Nodejs Production Checklist

4. Logging and Monitoring

Console.log is not enough for production. Use structured logging with a library like pino or winston. Log in JSON format so log aggregators (ELK, Datadog) can parse them. Include correlation IDs for request tracing. Log at appropriate levels: error, warn, info, debug. Never log sensitive data (passwords, tokens). Set up health check endpoints (e.g., /health) that return status of database, cache, and external services. Use APM tools like New Relic or Sentry for performance monitoring. Monitor memory usage, event loop lag, and garbage collection. Alerts should trigger on error rate spikes or high latency.

src/logger.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label })
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  redact: ['req.headers.authorization', 'req.body.password']
});

// Express middleware to log requests
const requestLogger = (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: Date.now() - start,
      correlationId: req.headers['x-correlation-id']
    });
  });
  next();
};

module.exports = { logger, requestLogger };
Output
Log line: {"level":"info","time":"2025-03-15T10:30:00.000Z","method":"GET","url":"/api/users","status":200,"duration":42,"correlationId":"abc-123"}
Try it live
🔥Use correlation IDs for debugging
Generate a unique ID per request (e.g., uuid) and pass it to all logs and downstream services. This lets you trace a single request across microservices.
📊 Production Insight
Without correlation IDs, we spent hours correlating logs from different services during an outage. Now we trace requests end-to-end in seconds.
🎯 Key Takeaway
Structured JSON logging with correlation IDs is essential for debugging production issues.

5. Security Best Practices

Production Node.js apps are frequent targets. Always use Helmet to set secure HTTP headers. Validate and sanitize all user input to prevent injection attacks. Use parameterized queries for databases. Implement rate limiting to prevent brute-force attacks. Use express-rate-limit or a reverse proxy like Nginx. Set up CORS properly — don't use wildcard in production. Use environment-specific CORS origins. Encrypt sensitive data at rest and in transit. Use bcrypt for password hashing. Keep dependencies updated with npm audit and use a tool like Snyk. Disable X-Powered-By header. Use csurf for CSRF protection if using cookies.

src/app.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cors = require('cors');

const app = express();

// Security headers
app.use(helmet());

// CORS
app.use(cors({
  origin: process.env.CORS_ORIGIN || 'https://myapp.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: 'Too many requests, please try again later.'
});
app.use('/api/', limiter);

// Remove fingerprinting
app.disable('x-powered-by');

// Input validation middleware (example)
const validateInput = (req, res, next) => {
  const { email } = req.body;
  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }
  next();
};
Output
Server responds with security headers (X-Content-Type-Options, X-Frame-Options, etc.). Rate limit returns 429 after 100 requests per 15 minutes.
Try it live
⚠ Don't roll your own crypto
Use well-vetted libraries like bcrypt for passwords and crypto for tokens. Custom implementations often have vulnerabilities.
📊 Production Insight
We once had a DDoS attack that hit our login endpoint. Rate limiting at the reverse proxy level saved us, but we also added it in the app as a second layer.
🎯 Key Takeaway
Apply security headers, input validation, rate limiting, and keep dependencies updated to prevent common attacks.

6. Database Connection Management

Database connections are expensive. Use a connection pool (e.g., pg-pool for PostgreSQL, mongoose for MongoDB) to reuse connections. Set pool size based on your database's max connections and your app's concurrency. Monitor pool usage — if you exhaust connections, requests will queue or fail. Use retry logic with exponential backoff for transient failures. Always close connections gracefully on shutdown. For read-heavy workloads, consider read replicas. Use environment-specific pool settings: smaller pool in development, larger in production. Also, use connection strings with SSL enabled in production.

src/db.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DB_URL,
  max: 20, // max connections in pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
  ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false
});

// Query wrapper with retry
async function query(text, params, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const result = await pool.query(text, params);
      return result;
    } catch (err) {
      if (i === retries - 1) throw err;
      console.warn(`Query failed, retrying (${i + 1}/${retries})`);
      await new Promise(res => setTimeout(res, 1000 * Math.pow(2, i)));
    }
  }
}

// Graceful shutdown
process.on('SIGTERM', async () => {
  await pool.end();
  process.exit(0);
});

module.exports = { query, pool };
Output
Pool maintains up to 20 connections. If a query fails, it retries up to 3 times with exponential backoff (1s, 2s, 4s). On SIGTERM, pool closes gracefully.
Try it live
💡Monitor pool usage
Use pool.totalCount and pool.waitingCount to detect connection leaks. Set up alerts when waiting count exceeds a threshold.
📊 Production Insight
A connection leak due to unclosed transactions exhausted our pool, causing a 5-minute outage. Adding monitoring and pool limits prevented recurrence.
🎯 Key Takeaway
Use connection pooling with retry logic and graceful shutdown to handle database connections reliably.

7. Caching Strategies

Caching reduces load on databases and improves response times. Use in-memory caching (e.g., node-cache) for single-instance apps, but for clustered or multi-server setups, use a distributed cache like Redis. Cache database query results, computed values, and API responses. Set appropriate TTLs based on data staleness tolerance. Use cache-aside pattern: check cache first, if miss, fetch from source and populate cache. Implement cache invalidation carefully — stale data can cause bugs. For high-traffic endpoints, consider write-through or write-behind caching. Monitor cache hit rates; low hit rates indicate poor cache strategy.

src/cache.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const redis = require('redis');
const { promisify } = require('util');

const client = redis.createClient({
  url: process.env.REDIS_URL,
  socket: {
    tls: process.env.NODE_ENV === 'production',
    reconnectStrategy: (retries) => Math.min(retries * 100, 3000)
  }
});

client.on('error', (err) => console.error('Redis error:', err));

const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);

async function getOrSet(key, fetchFn, ttl = 60) {
  const cached = await getAsync(key);
  if (cached) return JSON.parse(cached);

  const data = await fetchFn();
  await setAsync(key, JSON.stringify(data), 'EX', ttl);
  return data;
}

module.exports = { getOrSet, client };
Output
First request fetches from DB and caches in Redis for 60 seconds. Subsequent requests return cached data instantly.
Try it live
🔥Cache invalidation is hard
When data updates, you must invalidate or update the cache. Use event-driven invalidation (e.g., publish update event) or set short TTLs.
📊 Production Insight
We cached user profile data with a 1-hour TTL. After a bulk update, users saw stale data for an hour. Now we invalidate cache on write.
🎯 Key Takeaway
Use a distributed cache like Redis with appropriate TTLs and cache-aside pattern to reduce database load.

8. Graceful Shutdown and Health Checks

When your app receives a termination signal (SIGTERM from Kubernetes, PM2, etc.), you must shut down gracefully: stop accepting new requests, finish in-flight requests, close database connections, and then exit. Implement a health check endpoint that returns liveness and readiness. Liveness indicates the process is alive; readiness indicates it can serve traffic (e.g., database is connected). Kubernetes uses these to restart pods or stop routing traffic. Use http-shutdown or manual server closing. Set a timeout for forced exit to prevent hanging.

src/server.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const http = require('http');
const app = require('./app');
const { pool } = require('./db');

const server = http.createServer(app);

// Health check endpoint
app.get('/health', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    res.status(200).json({ status: 'healthy', uptime: process.uptime() });
  } catch (err) {
    res.status(503).json({ status: 'unhealthy' });
  }
});

// Graceful shutdown
async function shutdown(signal) {
  console.log(`Received ${signal}, shutting down gracefully...`);
  server.close(async () => {
    await pool.end();
    console.log('Closed all connections. Exiting.');
    process.exit(0);
  });

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

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

server.listen(process.env.PORT || 3000, () => {
  console.log(`Server listening on port ${process.env.PORT || 3000}`);
});
Output
On `kill -15 <pid>`, server stops accepting new connections, finishes ongoing requests, closes DB pool, and exits within 10 seconds.
Try it live
⚠ Don't ignore SIGTERM
If you don't handle SIGTERM, the process is killed forcefully, potentially corrupting data or leaving connections open.
📊 Production Insight
During a Kubernetes rolling update, pods without graceful shutdown caused 502 errors. Adding shutdown handlers eliminated the issue.
🎯 Key Takeaway
Implement graceful shutdown and health checks to ensure zero-downtime deployments and self-healing.

9. Dependency Management and CI/CD

Lock your dependencies with package-lock.json or yarn.lock. Use npm ci in CI for deterministic installs. Regularly run npm audit and fix vulnerabilities. Use a tool like snyk or dependabot for automated updates. In CI, run linting, tests, and security scans. Build artifacts (e.g., transpiled code) should be created in CI, not committed. Use multi-stage Docker builds to keep images small. Tag images with git commit hash for traceability. Deploy using blue-green or rolling updates. Automate rollback if health checks fail.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY dist/ ./dist/
COPY package.json ./

EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
Output
Final image is ~150MB (vs 1GB if using full Node image). Only production dependencies and compiled code are included.
💡Use npm ci, not npm install
npm ci installs from lockfile exactly, fails if lockfile is out of sync, and is faster. Use it in CI and production builds.
📊 Production Insight
A developer accidentally committed a dev dependency that introduced a vulnerability. npm audit in CI caught it before deployment.
🎯 Key Takeaway
Lock dependencies, automate security scans, and use multi-stage Docker builds for reliable and secure deployments.

10. Performance Optimization and Profiling

Profile your app under load to find bottlenecks. Use Node.js built-in profiler (--prof) or tools like clinic.js. Common issues: synchronous operations blocking the event loop, excessive garbage collection, memory leaks, and slow database queries. Use async for I/O. Avoid JSON.parse on large payloads in the main thread — offload to worker threads if needed. Use streaming for large responses. Implement response compression with compression middleware. Set NODE_ENV=production to enable optimizations (view caching, etc.). Use clinic to generate flamegraphs. Monitor event loop lag with process.hrtime.

src/middleware/performance.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const compression = require('compression');
const responseTime = require('response-time');

// Compression
app.use(compression());

// Response time header
app.use(responseTime((req, res, time) => {
  console.log(`${req.method} ${req.originalUrl} ${time}ms`);
}));

// Event loop lag monitoring
let lastCheck = process.hrtime.bigint();
setInterval(() => {
  const now = process.hrtime.bigint();
  const lag = Number(now - lastCheck) / 1e6; // ms
  if (lag > 50) {
    console.warn(`Event loop lag: ${lag.toFixed(2)}ms`);
  }
  lastCheck = now;
}, 1000);
Output
Responses are gzip-compressed. Response time header added. If event loop lag exceeds 50ms, a warning is logged.
Try it live
🔥Profile before optimizing
Don't guess bottlenecks. Use profiling tools to identify actual hot spots. Premature optimization wastes time.
📊 Production Insight
A synchronous JSON.parse on a 10MB request body blocked the event loop for 2 seconds, causing timeouts. We moved parsing to a worker thread.
🎯 Key Takeaway
Profile your app, use compression, monitor event loop lag, and avoid blocking the event loop.

11. Testing for Production Reliability

Unit tests alone are not enough. Write integration tests that test your API endpoints with a real database. Use supertest for HTTP testing. Write contract tests for microservices. Implement smoke tests that run after deployment to verify the app is healthy. Use load testing (e.g., artillery or k6) to find breaking points. Test error scenarios: database down, external API failure, invalid input. Use test containers for database dependencies. Aim for high code coverage but focus on critical paths. Run tests in CI on every push.

tests/integration/users.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const request = require('supertest');
const app = require('../src/app');
const { pool } = require('../src/db');

beforeAll(async () => {
  // Setup test database
  await pool.query('CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)');
});

afterAll(async () => {
  await pool.query('DROP TABLE IF EXISTS users');
  await pool.end();
});

describe('POST /api/users', () => {
  it('should create a user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'John' })
      .expect(201);
    expect(res.body).toHaveProperty('id');
  });

  it('should return 400 for invalid name', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: '' })
      .expect(400);
  });
});
Output
Tests pass: 2 passed, 0 failed. Database is cleaned up after tests.
Try it live
💡Use test containers for isolation
Run a real PostgreSQL in a Docker container for integration tests. Avoid mocking the database — it hides real issues.
📊 Production Insight
Our unit tests passed but integration tests failed because of a schema mismatch. Now we run integration tests in CI with a real database.
🎯 Key Takeaway
Write integration and smoke tests that run against real dependencies to catch production-like failures.

12. Deployment and Rollback Strategy

Use immutable deployments: build a new artifact (Docker image) for each version. Tag images with git commit hash. Use blue-green or canary deployments to minimize risk. Always have a rollback plan: keep the previous version running and switch back if health checks fail. Use feature flags to toggle features without redeploying. Automate the deployment pipeline with CI/CD (e.g., GitHub Actions, GitLab CI). Monitor error rates and latency after deployment. If error rate spikes, trigger automatic rollback. Document the rollback procedure.

.github/workflows/deploy.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .
      - name: Push to registry
        run: docker push myapp:${{ github.sha }}
      - name: Deploy to Kubernetes
        run: |
          kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}
          kubectl rollout status deployment/myapp
      - name: Smoke test
        run: |
          curl -f http://myapp.com/health || exit 1
      - name: Rollback on failure
        if: failure()
        run: kubectl rollout undo deployment/myapp
Output
On push to main, Docker image is built and pushed, Kubernetes deployment updated, smoke test runs. If smoke test fails, deployment is rolled back.
⚠ Always test rollback
Practice rollback in staging. A rollback that doesn't work is worse than no rollback. Ensure database migrations are backward-compatible.
📊 Production Insight
A bad deployment caused 500 errors for 10 minutes before manual rollback. Now we have automated rollback triggered by health check failures.
🎯 Key Takeaway
Use immutable deployments with automated rollback on health check failure to ensure safe releases.

Security Headers with Helmet.js

Helmet.js is a middleware that sets various HTTP security headers to protect your Express app from common web vulnerabilities. It bundles 15 smaller middleware functions, including CSP, X-Frame-Options, and X-Content-Type-Options. In production, you should configure Helmet with strict policies. For example, set Content-Security-Policy to restrict script sources and disable inline scripts unless hashed. Use helmet.contentSecurityPolicy() with directives like defaultSrc: ["'self'"] and scriptSrc: ["'self'", "'strict-dynamic'"]. Avoid using 'unsafe-inline' in production. Also, enable referrerPolicy: 'same-origin' and hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }. Test your headers with securityheaders.com. Remember that overly strict CSP can break third-party scripts, so iterate carefully.

app.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'strict-dynamic'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: []
    }
  },
  referrerPolicy: { policy: 'same-origin' },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }
}));
Output
HTTP headers set: Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Referrer-Policy.
Try it live
⚠ CSP Can Break Things
Start with report-only mode (contentSecurityPolicy: { useDefaults: false, directives: {...}, reportOnly: true }) to catch violations without blocking.
📊 Production Insight
Use Helmet's defaults for most headers, but always customize CSP for your app's specific resource needs.
🎯 Key Takeaway
Helmet.js sets essential security headers; configure CSP strictly but test in report-only mode first.

Rate Limiting with express-rate-limit

Rate limiting protects your API from abuse and brute-force attacks. Use express-rate-limit middleware to cap requests per IP. In production, set a generous limit for general endpoints (e.g., 100 requests per 15 minutes) and stricter limits for auth routes (e.g., 5 attempts per 15 minutes). Configure the middleware with windowMs, max, and a custom message. Use keyGenerator to rate-limit by user ID if authenticated. For distributed environments, use an external store like Redis via rate-limit-redis to share state across instances. Always return a Retry-After header. Monitor rate limit hits via logs or metrics. Avoid rate limiting health check endpoints. Example: create a separate limiter for /api/auth/login with max: 5 and windowMs: 15 60 1000.

rateLimiter.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redisClient = require('./redis');

const generalLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' }
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: 'Too many login attempts, please try again later.' },
  skipSuccessfulRequests: true
});

module.exports = { generalLimiter, authLimiter };
Output
Middleware that returns 429 status with JSON error when limit exceeded.
Try it live
💡Skip Rate Limiting for Health Checks
Use skip function to exempt health endpoints: skip: (req) => req.path === '/health'.
📊 Production Insight
Set rate limits based on expected traffic patterns; monitor and adjust after load testing.
🎯 Key Takeaway
Rate limit aggressively on auth routes, use Redis store for distributed apps, and skip health endpoints.

CORS Configuration

Cross-Origin Resource Sharing (CORS) controls which domains can access your API. In production, never use origin: '*'. Instead, whitelist specific origins. Use the cors package with an array of allowed origins or a function that validates the request origin. For example, origin: ['https://myapp.com', 'https://admin.myapp.com']. If you need to support dynamic origins (e.g., mobile apps), validate against a regex or a list. Also set credentials: true if using cookies or authorization headers. Restrict allowed methods (GET,POST,PUT,DELETE) and headers (Content-Type,Authorization). Set maxAge to cache preflight responses (e.g., 86400 seconds). For public APIs, consider using a reverse proxy to handle CORS. Always test with different origins in staging.

cors.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const cors = require('cors');

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

const corsOptions = {
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400
};

app.use(cors(corsOptions));
Output
Only requests from allowed origins with proper methods/headers succeed.
Try it live
⚠ Don't Trust Origin Header Blindly
The Origin header can be spoofed; use CORS as a browser-enforced policy, not as an authentication mechanism.
📊 Production Insight
Use environment variables for allowed origins to avoid hardcoding.
🎯 Key Takeaway
Whitelist specific origins, enable credentials only if needed, and cache preflight responses.

Input Validation and Sanitization

Always validate and sanitize user input to prevent injection attacks and data corruption. Use a schema validation library like Joi or Zod for request bodies, query params, and route params. Define strict schemas with expected types, lengths, and patterns. Sanitize strings to remove HTML tags using sanitize-html or xss. For MongoDB, use mongo-sanitize to prevent NoSQL injection. Never trust req.body, req.query, or req.params directly. Validate at the middleware level before any business logic. Return clear error messages but avoid exposing internal details. For file uploads, validate MIME types and file size. Use express-validator for simple cases, but prefer Joi for complex schemas. Example: validate email with Joi.string().email().required(). Always normalize Unicode characters to prevent homograph attacks.

validation.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Joi = require('joi');

const userSchema = Joi.object({
  name: Joi.string().min(2).max(50).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)')).required(),
  role: Joi.string().valid('user', 'admin').default('user')
});

function validateUser(req, res, next) {
  const { error } = userSchema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(400).json({ errors: error.details.map(d => d.message) });
  }
  next();
}

app.post('/api/users', validateUser, handler);
Output
Returns 400 with array of error messages if validation fails.
Try it live
💡Sanitize Before Storing
Use sanitize-html to strip dangerous tags from user-generated content before saving to DB.
📊 Production Insight
Centralize validation logic in middleware to keep controllers clean and consistent.
🎯 Key Takeaway
Validate all input with a schema library, sanitize strings, and never trust raw request data.

Gzip Compression

Enable gzip compression to reduce response size and improve load times. Use the compression middleware in Express. In production, set compression level to 6 (balance between speed and ratio). Filter out already compressed responses (e.g., images, videos) using the filter option. For high-traffic apps, offload compression to a reverse proxy like Nginx. Test with curl -H "Accept-Encoding: gzip" -o /dev/null -w "%{size_download}" to compare sizes. Be aware that compression adds CPU overhead; monitor server load. For dynamic content, cache compressed responses in Redis. Example: app.use(compression({ level: 6, threshold: 1024 })) compresses responses larger than 1KB. Always set Vary: Accept-Encoding header (handled automatically).

app.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
const compression = require('compression');

app.use(compression({
  level: 6,
  threshold: 1024,
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));
Output
Responses larger than 1KB are gzip compressed; client must send Accept-Encoding: gzip.
Try it live
💡Don't Compress Already Compressed Assets
The default filter skips responses with Content-Type image/, video/, etc.
📊 Production Insight
Monitor CPU usage; if high, consider using Brotli compression (supported by modern browsers) for better ratios.
🎯 Key Takeaway
Enable gzip compression with level 6 and threshold 1KB; offload to reverse proxy for high traffic.
Process Manager vs Manual Clustering Trade-offs in Node.js production process management PM2 Process Manager Manual Cluster Module Setup Complexity Low (one command) High (custom code) Auto Restart Built-in on crash Must implement manually Zero Downtime Reload Supported natively Requires custom logic Resource Monitoring Built-in dashboard None, needs external tool Configuration Flexibility Limited to PM2 options Full control over clustering Learning Curve Minimal Steep for production readiness THECODEFORGE.IO
thecodeforge.io
Nodejs Production Checklist

Dependency Vulnerability Scanning

Regularly scan your dependencies for known vulnerabilities using npm audit and Snyk. Run npm audit in CI to fail builds on high-severity issues. Use npm audit fix to auto-fix where possible, but review changes. For deeper scanning, integrate Snyk into your pipeline: snyk test and snyk monitor. Snyk provides real-time alerts and fix PRs. Also scan your Docker images with snyk container test or Trivy. In your Dockerfile, use multi-stage builds to minimize attack surface. Pin base image versions (e.g., node:18-alpine). Regularly update dependencies with npm update or tools like Renovate. For production, consider using a private registry with vulnerability scanning. Example CI step: npm audit --audit-level=high exits with code 1 if any high severity found.

.github/workflows/ci.ymlBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: CI
on: [push]
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm ci
      - run: npm audit --audit-level=high
      - name: Snyk Scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
Output
Fails CI if any high severity vulnerability found.
⚠ Don't Ignore Dev Dependencies
Vulnerabilities in devDependencies can still affect your build pipeline; scan them too.
📊 Production Insight
Automate dependency updates with Renovate or Dependabot, and review changelogs before merging.
🎯 Key Takeaway
Run npm audit in CI, integrate Snyk for continuous monitoring, and scan Docker images.
● Production incidentPOST-MORTEMseverity: high

The Silent Memory Leak That Took Down Our API Every 48 Hours

Symptom
API response times gradually increased over 48 hours, then the process ran out of memory and crashed. Restarting fixed it temporarily.
Assumption
The team assumed it was a traffic spike or a bug in a third-party library, so they added more instances and increased memory limits.
Root cause
A new feature endpoint opened a MongoDB connection using MongoClient.connect() inside each request handler but never called client.close(). Over time, thousands of dangling connections consumed all memory.
Fix
Refactored to use a connection pool (Mongoose) that reuses connections, and added a middleware to close any open connections on response finish. Also added a memory leak detection script using heap snapshots.
Key lesson
  • Always use connection pooling for databases; never open a new connection per request.
  • Monitor memory usage and set up alerts for gradual increases.
  • Add automated tests that simulate long-running requests to catch resource leaks.
  • Use tools like clinic.js or heapdump to profile memory in staging before deploying to production.
⚙ Quick Reference
18 commands from this guide
FileCommand / CodePurpose
ecosystem.config.jsmodule.exports = {1. Process Management and Clustering
configindex.jsconst envalid = require('envalid');2. Environment Configuration Management
srcmiddlewareerrorHandler.jsconst createError = require('http-errors');3. Error Handling and Uncaught Exceptions
srclogger.jsconst pino = require('pino');4. Logging and Monitoring
srcapp.jsconst express = require('express');5. Security Best Practices
srcdb.jsconst { Pool } = require('pg');6. Database Connection Management
srccache.jsconst redis = require('redis');7. Caching Strategies
srcserver.jsconst http = require('http');8. Graceful Shutdown and Health Checks
DockerfileFROM node:18-alpine AS builder9. Dependency Management and CI/CD
srcmiddlewareperformance.jsconst compression = require('compression');10. Performance Optimization and Profiling
testsintegrationusers.test.jsconst request = require('supertest');11. Testing for Production Reliability
.githubworkflowsdeploy.ymlname: Deploy12. Deployment and Rollback Strategy
app.jsconst helmet = require('helmet');Security Headers with Helmet.js
rateLimiter.jsconst rateLimit = require('express-rate-limit');Rate Limiting with express-rate-limit
cors.jsconst cors = require('cors');CORS Configuration
validation.jsconst Joi = require('joi');Input Validation and Sanitization
app.jsconst compression = require('compression');Gzip Compression
.githubworkflowsci.ymlname: CIDependency Vulnerability Scanning

Key takeaways

1
Process Management
Always run Node.js in cluster mode with a process manager like PM2 to ensure high availability and resource utilization.
2
Error Handling
Handle all errors centrally, log them, and crash on uncaught exceptions; let the process manager restart the app.
3
Security
Apply security headers, input validation, rate limiting, and keep dependencies updated to prevent common attacks.
4
Deployment
Use immutable deployments with automated rollback on health check failure to ensure safe releases.
5
Security Headers
Use Helmet.js with a strict CSP (report-only first), HSTS, and Referrer-Policy. Customize beyond defaults.
6
Rate Limiting
Apply express-rate-limit with Redis store for distributed apps; set stricter limits on auth routes and skip health endpoints.
7
Dependency Scanning
Run npm audit in CI, integrate Snyk for continuous monitoring, and scan Docker images with Trivy or Snyk.
8
Security Headers
Use Helmet.js to set HTTP security headers. Customize CSP with nonces for SPAs and test in report-only mode.
9
Input Validation
Always validate and sanitize inputs server-side using a schema library. Whitelist allowed values and escape output.
10
Dependency Scanning
Regularly scan dependencies with npm audit and Snyk. Integrate into CI and automate updates with Dependabot.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the purpose of using a process manager like PM2 in production?
Q02SENIOR
How would you handle uncaught exceptions in a Node.js production applica...
Q03SENIOR
Explain the event loop and how a long-running synchronous operation can ...
Q04SENIOR
What are some common security vulnerabilities in Node.js and how do you ...
Q05SENIOR
How do you monitor a Node.js application in production?
Q06SENIOR
Describe a strategy for zero-downtime deployments with Node.js.
Q01 of 06JUNIOR

What is the purpose of using a process manager like PM2 in production?

ANSWER
PM2 keeps your Node.js app alive by automatically restarting it if it crashes, managing logs, and enabling zero-downtime deployments. It also helps with clustering to utilize multiple CPU cores.
FAQ · 12 QUESTIONS

Frequently Asked Questions

01
Why should I use a process manager like PM2 instead of the built-in cluster module?
02
How do I handle environment variables in production without using .env files?
03
What is the difference between liveness and readiness probes in Kubernetes?
04
How do I prevent memory leaks in Node.js production apps?
05
Should I use npm install or npm ci in production?
06
What is the best way to log in production?
07
Should I use Helmet.js defaults or customize them for production?
08
How do I handle CORS for a mobile app or server-to-server communication?
09
What's the best way to validate and sanitize user input in Express?
10
How do I configure Helmet.js for a single-page application (SPA) that uses inline scripts?
11
Should I use npm audit or Snyk for vulnerability scanning?
12
How do I handle CORS preflight caching to reduce OPTIONS requests?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's Node.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
Building CLI Tools with Node.js and Commander
45 / 47 · Node.js
Next
Introduction to TypeScript