|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/deployment
$cat docs/deployment.md
updated Recently·20-28 min read·published

Deployment

Node.jsDeploymentDevOpsAdvanced🎯Free Tools
Introduction

A production-grade Node.js deployment combines runtime knowledge, robust HTTP handling, framework patterns, security hardening, automated testing, performance tuning, and reliable operations. This guide connects every layer you need to ship services that survive real traffic.

All examples target Node.js 20+. We cover path/os/process, native http, Express, Fastify, security, testing, performance, and packaging with Docker, PM2, and CI/CD.

path, os & process

Portable paths and environment awareness are prerequisites for any server. Node.js ships path and os for this, while process exposes arguments, environment variables, signals, and lifecycle events.

path-os.mjs
JavaScript
1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import os from 'node:os';
4
5const __filename = fileURLToPath(import.meta.url);
6const __dirname = path.dirname(__filename);
7
8const uploadDir = path.join(__dirname, '..', 'uploads');
9const parsed = path.parse('/var/www/app/index.js');
10
11console.log(os.platform(), os.cpus().length, os.totalmem());
12console.log(os.homedir(), os.tmpdir());
13process.env.NODE_ENV ??= 'development';
14const start = process.hrtime.bigint();

Graceful signal handling

Containers send SIGTERM to request a clean shutdown. Close servers, flush logs, and finish in-flight requests.

graceful-shutdown.mjs
JavaScript
1function gracefulShutdown(server, signal) {
2 console.log(` + '`' + `Received ${signal}, shutting down...` + '`' + `);
3 server.close((err) => {
4 if (err) return process.exit(1);
5 process.exit(0);
6 });
7 setTimeout(() => process.exit(1), 30_000).unref();
8}
9
10process.on('SIGTERM', () => gracefulShutdown(server, 'SIGTERM'));
11process.on('SIGINT', () => gracefulShutdown(server, 'SIGINT'));
12process.on('unhandledRejection', (reason) => { throw reason; });

warning

In Docker and Kubernetes, PID 1 does not receive default signal actions unless you use exec in the entrypoint. Without SIGTERM handling, containers wait the full termination grace period.
HTTP Server

The native node:http module is the foundation every framework builds on. Understanding request/response lifecycle, streams, headers, and backpressure lets you debug framework issues and build lightweight services.

http-server.mjs
JavaScript
1import http from 'node:http';
2import { URL } from 'node:url';
3
4const server = http.createServer((req, res) => {
5 const url = new URL(req.url, ` + '`' + `http://${req.headers.host}` + '`' + `);
6 res.setHeader('Content-Type', 'application/json');
7 res.setHeader('X-Content-Type-Options', 'nosniff');
8
9 if (req.method === 'GET' && url.pathname === '/health') {
10 res.writeHead(200);
11 return res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
12 }
13
14 if (req.method === 'POST' && url.pathname === '/echo') {
15 let body = '';
16 req.setEncoding('utf8');
17 req.on('data', (chunk) => { body += chunk; });
18 req.on('end', () => res.end(body));
19 return;
20 }
21
22 res.writeHead(404);
23 res.end(JSON.stringify({ error: 'not found' }));
24});
25
26server.listen(3000, () => console.log('listening on 3000'));
json-body-parser.mjs
JavaScript
1async function readJson(req, limit = 1_000_000) {
2 const chunks = [];
3 let size = 0;
4 for await (const chunk of req) {
5 size += chunk.length;
6 if (size > limit) {
7 const err = new Error('Payload too large');
8 err.statusCode = 413;
9 throw err;
10 }
11 chunks.push(chunk);
12 }
13 try {
14 return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
15 } catch {
16 const err = new Error('Invalid JSON');
17 err.statusCode = 400;
18 throw err;
19 }
20}

best practice

Never build your own static file server for real traffic — use nginx, a CDN, or hardened middleware. Resolve requested paths and verify they stay inside the public directory to prevent directory traversal.
Express.js

Express remains the most widely used Node.js framework. Its middleware model is simple but order-dependent: each request passes through the stack in sequence until a handler sends a response or calls next().

express-app.mjs
JavaScript
1import express from 'express';
2import helmet from 'helmet';
3
4const app = express();
5
6app.use(helmet());
7app.use(express.json({ limit: '1mb' }));
8app.use(express.urlencoded({ extended: true }));
9
10app.use((req, res, next) => {
11 console.log(`${req.method} ${req.path}`);
12 next();
13});
14
15app.get('/users/:id', (req, res) => {
16 res.json({ id: req.params.id, query: req.query });
17});
18
19app.post('/users', (req, res) => {
20 const { name, email } = req.body;
21 if (!name || !email) {
22 return res.status(400).json({ error: 'name and email required' });
23 }
24 res.status(201).json({ id: 1, name, email });
25});
26
27app.use((req, res) => res.status(404).json({ error: 'not found' }));
28
29// Error handling middleware must have 4 arguments
30app.use((err, req, res, next) => {
31 console.error(err);
32 res.status(err.statusCode || 500).json({ error: err.message });
33});
34
35app.listen(3000);

Express 5 catches rejected promises automatically. In Express 4, wrap async routes so thrown errors reach the error handler.

express-async.mjs
JavaScript
1const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
2
3app.get('/users/:id', asyncHandler(async (req, res) => {
4 const user = await db.user.findById(req.params.id);
5 if (!user) {
6 const err = new Error('User not found');
7 err.statusCode = 404;
8 throw err;
9 }
10 res.json(user);
11}));

warning

Middleware order matters. Mount express.json() before routes, and place error-handling middleware after routes.
Fastify

Fastify is a performance-focused framework built around plugins, hooks, and JSON schemas. Its encapsulation model makes large applications easier to reason about than flat Express middleware stacks, and its built-in JSON parser is significantly faster.

fastify-app.mjs
JavaScript
1import Fastify from 'fastify';
2
3const app = Fastify({ logger: true, pluginTimeout: 10_000 });
4
5const userSchema = {
6 schema: {
7 body: {
8 type: 'object',
9 required: ['name', 'email'],
10 properties: {
11 name: { type: 'string', minLength: 1 },
12 email: { type: 'string', format: 'email' },
13 age: { type: 'integer', minimum: 0 },
14 },
15 },
16 response: {
17 201: {
18 type: 'object',
19 properties: { id: { type: 'integer' }, name: { type: 'string' }, email: { type: 'string' } },
20 },
21 },
22 },
23};
24
25app.post('/users', userSchema, async (req, reply) => {
26 const { name, email } = req.body;
27 const id = await db.user.create({ name, email });
28 reply.status(201).send({ id, name, email });
29});
30
31app.get('/health', async () => ({ status: 'ok' }));
32app.listen({ port: 3000, host: '0.0.0.0' });

Hooks run at lifecycle points and decorators attach reusable utilities. Both are encapsulated per plugin scope unless applied to the root instance.

fastify-hooks.mjs
JavaScript
1app.addHook('onRequest', async (req) => { req.startTime = process.hrtime.bigint(); });
2app.addHook('onResponse', async (req, reply) => {
3 const ms = Number(process.hrtime.bigint() - req.startTime) / 1e6;
4 req.log.info({ path: req.url, statusCode: reply.statusCode, ms });
5});
6app.decorate('cache', new Map());
7app.get('/cache/:key', async (req) => app.cache.get(req.params.key) ?? { value: null });

info

Fastify routes automatically return rejected promises to the error handler, removing try/catch boilerplate. JSON schema validation removes the need for separate validation libraries in many APIs.
AspectExpressFastify
ValidationExternal (zod, joi)Built-in JSON schema
Async errorsManual forwarding (v4)Caught automatically
EncapsulationFlat middleware stackPlugin scopes
ThroughputModerate~2x faster JSON parsing
Security

Security is a deployment concern, not a feature you bolt on later. The minimal baseline is secure headers, strict input validation, secret management, CORS lockdown, and dependency scanning.

security-basics.mjs
JavaScript
1import helmet from 'helmet';
2import cors from 'cors';
3import { z } from 'zod';
4import express from 'express';
5
6const app = express();
7
8app.use(helmet({
9 contentSecurityPolicy: {
10 directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"] },
11 },
12 hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
13}));
14
15app.use(cors({
16 origin: process.env.ALLOWED_ORIGIN || 'http://localhost:3000',
17 credentials: true,
18}));
19
20const UserSchema = z.object({
21 name: z.string().min(1).max(200),
22 email: z.string().email(),
23 age: z.number().int().min(0).optional(),
24});
25
26app.post('/users', async (req, res, next) => {
27 const result = UserSchema.safeParse(req.body);
28 if (!result.success) return res.status(400).json({ issues: result.error.issues });
29 try {
30 const user = await db.user.create(result.data);
31 res.status(201).json(user);
32 } catch (err) { next(err); }
33});

Common vulnerabilities

VulnerabilityMitigation
Path traversalResolve paths and verify prefix; never use raw user input in fs calls
SSRFDeny private IP ranges; validate URLs against an allowlist
InjectionParameterized queries, schema validation, avoid eval/new Function
Secret leakageLoad from env, never commit .env, scan with GitLeaks or TruffleHog
DoSRate limiting, body size limits, timeouts, resource quotas

danger

Never disable helmet or set cors({ origin: '*' }) in production. Wildcard CORS with credentials enabled exposes authenticated requests to malicious sites.
Testing

Node.js 20 ships a built-in test runner (node:test) and assertion library (node:assert). It is viable for many projects without Jest or Vitest, though Vitest remains popular for watch mode and TypeScript support.

node-test.mjs
JavaScript
1import { test, describe, mock } from 'node:test';
2import assert from 'node:assert/strict';
3import { add, createUser } from './lib.mjs';
4
5describe('math', () => {
6 test('adds two numbers', () => assert.equal(add(2, 3), 5));
7 test('throws on non-numbers', () => assert.throws(() => add('2', 3), /numbers required/));
8});
9
10describe('users', () => {
11 test('creates a user', async () => {
12 const db = { insert: mock.fn(async (u) => ({ id: 1, ...u })) };
13 const user = await createUser(db, { name: 'Ada', email: 'ada@example.com' });
14 assert.equal(user.id, 1);
15 assert.equal(db.insert.mock.callCount(), 1);
16 });
17});

HTTP integration tests with Supertest

Export your app without calling listen() and use Supertest to send requests. Reset the database or seed fixtures before each test and avoid sharing mutable global state.

integration-test.mjs
JavaScript
1import request from 'supertest';
2import { test, before, after } from 'node:test';
3import assert from 'node:assert/strict';
4import app from '../app.mjs';
5
6let server;
7before(async () => { await db.migrate.latest(); server = app.listen(0); });
8after(async () => { await new Promise((res) => server.close(res)); await db.destroy(); });
9
10test('POST /users creates a user', async () => {
11 const res = await request(app)
12 .post('/users')
13 .send({ name: 'Grace', email: 'grace@example.com' })
14 .expect(201);
15 assert.equal(res.body.name, 'Grace');
16 assert.ok(res.body.id);
17});

best practice

Do not test against production databases or external APIs in CI. Use an ephemeral test database, HTTP mocks, or a local container. Test the contract, not third-party uptime.
Performance

Node.js is single-threaded with an event loop. CPU-heavy work blocks all concurrent requests, so scale out with the cluster module or offload CPU work to worker_threads.

cluster.mjs
JavaScript
1import cluster from 'node:cluster';
2import os from 'node:os';
3import http from 'node:http';
4
5if (cluster.isPrimary) {
6 const cpus = os.availableParallelism();
7 for (let i = 0; i < cpus; i++) cluster.fork();
8 cluster.on('exit', (worker) => {
9 console.log(` + '`' + `worker ${worker.process.pid} died, restarting` + '`' + `);
10 cluster.fork();
11 });
12} else {
13 http.createServer((req, res) => {
14 res.end(` + '`' + `hello from ${process.pid}` + '`' + `);
15 }).listen(3000);
16}
worker-threads.mjs
JavaScript
1import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
2
3if (isMainThread) {
4 const worker = new Worker(import.meta.filename, { workerData: { n: 40 } });
5 worker.on('message', (result) => console.log('fib', result));
6 worker.on('error', console.error);
7} else {
8 function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
9 parentPort.postMessage(fib(workerData.n));
10}

info

The most common production memory leak is unbounded caches and event listeners that are never removed. Cap cache size with TTLs or LRU eviction and remove listeners when subscribers disconnect.
Deployment & Operations

Production Node.js runs either via PM2 on a VM or in a container orchestrated by Docker, Kubernetes, or a platform service. Both paths require environment variables, health checks, log aggregation, and graceful shutdown.

PM2 ecosystem configuration

ecosystem.config.js
JavaScript
1module.exports = {
2 apps: [{
3 name: 'api',
4 script: './src/index.mjs',
5 instances: 'max',
6 exec_mode: 'cluster',
7 env: { NODE_ENV: 'production', PORT: 3000 },
8 log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
9 error_file: './logs/err.log',
10 out_file: './logs/out.log',
11 merge_logs: true,
12 max_memory_restart: '512M',
13 kill_timeout: 30_000,
14 }],
15};

Docker multi-stage build

Build in one stage, copy artifacts into a minimal runtime image, and run as a non-root user.

Dockerfile
Dockerfile
1# syntax=docker/dockerfile:1
2FROM node:20-alpine AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9FROM node:20-alpine AS runtime
10ENV NODE_ENV=production
11WORKDIR /app
12RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
13COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
14COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
15COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
16USER nodejs
17EXPOSE 3000
18HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
19 CMD node --eval "require('http').get('http://localhost:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
20CMD ["node", "dist/index.mjs"]

Environment variables and secrets

Validate environment variables at startup and fail fast if required values are missing. Use a secrets manager for sensitive values.

env-validation.mjs
JavaScript
1import { z } from 'zod';
2
3const envSchema = z.object({
4 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
5 PORT: z.string().transform(Number).default('3000'),
6 DATABASE_URL: z.string().url(),
7 JWT_SECRET: z.string().min(32),
8});
9
10const parsed = envSchema.safeParse(process.env);
11if (!parsed.success) {
12 console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
13 process.exit(1);
14}
15export const env = parsed.data;

CI/CD basics

.github/workflows/ci.yml
YAML
1name: CI
2on: [push]
3jobs:
4 build:
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v4
8 - uses: actions/setup-node@v4
9 with:
10 node-version: 20
11 cache: npm
12 - run: npm ci
13 - run: npx tsc --noEmit
14 - run: npm run lint
15 - run: npm test
16 - run: npm run build
17 - name: Build image
18 run: docker build -t myapp:${{ github.sha }} .

best practice

Treat health checks as a deployment contract. A failing health check should stop traffic and trigger a rollback. Log in structured JSON so aggregation tools can correlate requests across services.
$Blueprint — Engineering Documentation·Section ID: NODE-DEPLOY-01·Revision: 1.0