Deployment
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.
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.
| 1 | import path from 'node:path'; |
| 2 | import { fileURLToPath } from 'node:url'; |
| 3 | import os from 'node:os'; |
| 4 | |
| 5 | const __filename = fileURLToPath(import.meta.url); |
| 6 | const __dirname = path.dirname(__filename); |
| 7 | |
| 8 | const uploadDir = path.join(__dirname, '..', 'uploads'); |
| 9 | const parsed = path.parse('/var/www/app/index.js'); |
| 10 | |
| 11 | console.log(os.platform(), os.cpus().length, os.totalmem()); |
| 12 | console.log(os.homedir(), os.tmpdir()); |
| 13 | process.env.NODE_ENV ??= 'development'; |
| 14 | const start = process.hrtime.bigint(); |
Graceful signal handling
Containers send SIGTERM to request a clean shutdown. Close servers, flush logs, and finish in-flight requests.
| 1 | function 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 | |
| 10 | process.on('SIGTERM', () => gracefulShutdown(server, 'SIGTERM')); |
| 11 | process.on('SIGINT', () => gracefulShutdown(server, 'SIGINT')); |
| 12 | process.on('unhandledRejection', (reason) => { throw reason; }); |
warning
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.
| 1 | import http from 'node:http'; |
| 2 | import { URL } from 'node:url'; |
| 3 | |
| 4 | const 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 | |
| 26 | server.listen(3000, () => console.log('listening on 3000')); |
| 1 | async 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
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().
| 1 | import express from 'express'; |
| 2 | import helmet from 'helmet'; |
| 3 | |
| 4 | const app = express(); |
| 5 | |
| 6 | app.use(helmet()); |
| 7 | app.use(express.json({ limit: '1mb' })); |
| 8 | app.use(express.urlencoded({ extended: true })); |
| 9 | |
| 10 | app.use((req, res, next) => { |
| 11 | console.log(`${req.method} ${req.path}`); |
| 12 | next(); |
| 13 | }); |
| 14 | |
| 15 | app.get('/users/:id', (req, res) => { |
| 16 | res.json({ id: req.params.id, query: req.query }); |
| 17 | }); |
| 18 | |
| 19 | app.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 | |
| 27 | app.use((req, res) => res.status(404).json({ error: 'not found' })); |
| 28 | |
| 29 | // Error handling middleware must have 4 arguments |
| 30 | app.use((err, req, res, next) => { |
| 31 | console.error(err); |
| 32 | res.status(err.statusCode || 500).json({ error: err.message }); |
| 33 | }); |
| 34 | |
| 35 | app.listen(3000); |
Express 5 catches rejected promises automatically. In Express 4, wrap async routes so thrown errors reach the error handler.
| 1 | const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); |
| 2 | |
| 3 | app.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
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.
| 1 | import Fastify from 'fastify'; |
| 2 | |
| 3 | const app = Fastify({ logger: true, pluginTimeout: 10_000 }); |
| 4 | |
| 5 | const 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 | |
| 25 | app.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 | |
| 31 | app.get('/health', async () => ({ status: 'ok' })); |
| 32 | app.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.
| 1 | app.addHook('onRequest', async (req) => { req.startTime = process.hrtime.bigint(); }); |
| 2 | app.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 | }); |
| 6 | app.decorate('cache', new Map()); |
| 7 | app.get('/cache/:key', async (req) => app.cache.get(req.params.key) ?? { value: null }); |
info
| Aspect | Express | Fastify |
|---|---|---|
| Validation | External (zod, joi) | Built-in JSON schema |
| Async errors | Manual forwarding (v4) | Caught automatically |
| Encapsulation | Flat middleware stack | Plugin scopes |
| Throughput | Moderate | ~2x faster JSON parsing |
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.
| 1 | import helmet from 'helmet'; |
| 2 | import cors from 'cors'; |
| 3 | import { z } from 'zod'; |
| 4 | import express from 'express'; |
| 5 | |
| 6 | const app = express(); |
| 7 | |
| 8 | app.use(helmet({ |
| 9 | contentSecurityPolicy: { |
| 10 | directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"] }, |
| 11 | }, |
| 12 | hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, |
| 13 | })); |
| 14 | |
| 15 | app.use(cors({ |
| 16 | origin: process.env.ALLOWED_ORIGIN || 'http://localhost:3000', |
| 17 | credentials: true, |
| 18 | })); |
| 19 | |
| 20 | const 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 | |
| 26 | app.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
| Vulnerability | Mitigation |
|---|---|
| Path traversal | Resolve paths and verify prefix; never use raw user input in fs calls |
| SSRF | Deny private IP ranges; validate URLs against an allowlist |
| Injection | Parameterized queries, schema validation, avoid eval/new Function |
| Secret leakage | Load from env, never commit .env, scan with GitLeaks or TruffleHog |
| DoS | Rate limiting, body size limits, timeouts, resource quotas |
danger
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.
| 1 | import { test, describe, mock } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import { add, createUser } from './lib.mjs'; |
| 4 | |
| 5 | describe('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 | |
| 10 | describe('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.
| 1 | import request from 'supertest'; |
| 2 | import { test, before, after } from 'node:test'; |
| 3 | import assert from 'node:assert/strict'; |
| 4 | import app from '../app.mjs'; |
| 5 | |
| 6 | let server; |
| 7 | before(async () => { await db.migrate.latest(); server = app.listen(0); }); |
| 8 | after(async () => { await new Promise((res) => server.close(res)); await db.destroy(); }); |
| 9 | |
| 10 | test('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
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.
| 1 | import cluster from 'node:cluster'; |
| 2 | import os from 'node:os'; |
| 3 | import http from 'node:http'; |
| 4 | |
| 5 | if (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 | } |
| 1 | import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'; |
| 2 | |
| 3 | if (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
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
| 1 | module.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.
| 1 | # syntax=docker/dockerfile:1 |
| 2 | FROM node:20-alpine AS builder |
| 3 | WORKDIR /app |
| 4 | COPY package*.json ./ |
| 5 | RUN npm ci |
| 6 | COPY . . |
| 7 | RUN npm run build |
| 8 | |
| 9 | FROM node:20-alpine AS runtime |
| 10 | ENV NODE_ENV=production |
| 11 | WORKDIR /app |
| 12 | RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 |
| 13 | COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist |
| 14 | COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules |
| 15 | COPY --from=builder --chown=nodejs:nodejs /app/package.json ./ |
| 16 | USER nodejs |
| 17 | EXPOSE 3000 |
| 18 | HEALTHCHECK --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))" |
| 20 | CMD ["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.
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const 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 | |
| 10 | const parsed = envSchema.safeParse(process.env); |
| 11 | if (!parsed.success) { |
| 12 | console.error('Invalid environment:', parsed.error.flatten().fieldErrors); |
| 13 | process.exit(1); |
| 14 | } |
| 15 | export const env = parsed.data; |
CI/CD basics
| 1 | name: CI |
| 2 | on: [push] |
| 3 | jobs: |
| 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