Production & Deployment

Node.js Advanced — Production & Deployment

Node.js Advanced

Chapter 8 of 8  ·  Production & Deployment

Production & Deployment

Getting an app to work locally is the easy part. Getting it to run reliably under real load, restart cleanly after crashes, deploy without dropping requests, surface problems before users do, and recover quickly when something goes wrong — that's production. This final chapter is a checklist-driven survey of everything a Node.js application needs before it earns the right to serve real traffic: a production-ready Docker image, the 12-factor disciplines, structured logging, metrics, zero-downtime deployment strategies, and an incident-response mindset.

The 12-Factor App

A methodology from Heroku (2011) that has become the reference standard for cloud-native applications. Every factor is directly applicable to Node.js.

#FactorWhat it means for Node
ICodebaseOne repo, many deploys. No per-environment branches.
IIDependenciespackage.json + package-lock.json. No assumed system-wide packages. npm ci in production.
IIIConfigAll config in environment variables — never in code or committed files. process.env.DATABASE_URL, not hardcoded strings.
IVBacking servicesDB, Redis, S3 are attached resources accessed by URL. Swap dev DB for prod DB by changing one env var, no code change.
VBuild/Release/RunStrictly separate stages: npm ci (build) → image tag (release) → container start (run). Never patch a running container.
VIProcessesStateless and share-nothing. Session state in Redis, files in S3 — not in the process's memory or local disk.
VIIPort bindingapp.listen(process.env.PORT). The app exports its own HTTP server; the platform maps the port.
VIIIConcurrencyScale out by running more processes (containers), not by adding threads. Node's cluster module or multiple containers behind a load balancer.
IXDisposabilityFast startup (<5s), graceful shutdown on SIGTERM. Jobs in a queue survive a crash; in-flight requests drain before exit.
XDev/prod paritySame Node version, same backing service versions (same Redis image in dev as prod). Docker Compose keeps parity.
XILogsWrite to stdout only. Never open log files in the app. The platform collects, routes, and stores logs.
XIIAdmin processesOne-off tasks (migrations, scripts) run in the same environment as the app, as ephemeral processes, not special long-lived servers.

Docker — Production-Ready Dockerfile

# Multi-stage build: builder installs deps + transpiles; runner is lean # Stage 1 — deps only (cached separately from source) FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev # production deps only; --omit=dev replaces --production # Stage 2 — final image (no build tools, no devDependencies) FROM node:22-alpine AS runner WORKDIR /app # Security: run as a non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser # Copy only what the app needs at runtime COPY --chown=appuser:appgroup --from=deps /app/node_modules ./node_modules COPY --chown=appuser:appgroup . . # Declare the port the app binds to (documentation only; docker run -p maps it) EXPOSE 3000 # Health check — docker ps HEALTHY/UNHEALTHY columns HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/health/live || exit 1 # node not npm — so SIGTERM reaches Node, not npm's wrapper process CMD ["node", "src/server.js"]
# .dockerignore — keep the build context small and secrets out of the image node_modules .git .env .env.* *.log coverage .nyc_output tests docs
# docker-compose.yml — local dev with parity to production version: '3.9' services: app: build: . ports: ['3000:3000'] environment: NODE_ENV: development DATABASE_URL: postgres://user:pass@db:5432/myapp REDIS_URL: redis://redis:6379 depends_on: [db, redis] volumes: - .:/app # mount source for hot-reload in dev - /app/node_modules # anonymous volume preserves container's node_modules db: image: postgres:16-alpine environment: { POSTGRES_PASSWORD: pass, POSTGRES_USER: user, POSTGRES_DB: myapp } volumes: [pgdata:/var/lib/postgresql/data] redis: image: redis:7-alpine volumes: pgdata:

Structured Logging with Pino

Plain text logs are for humans reading a terminal. Production logs go into Elasticsearch, Datadog, or CloudWatch — they need to be machine-parseable JSON. Pino is the fastest Node.js JSON logger (asynchronous, minimal overhead) and is the default in Fastify, but works equally well with Express.

// npm install pino pino-http const pino = require('pino'); const logger = pino({ level: process.env.LOG_LEVEL ?? 'info', // In production: no pretty-printing, raw JSON to stdout // In dev: pipe through 'pino-pretty' for readable output transport: process.env.NODE_ENV === 'development' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, base: { service: process.env.SERVICE_NAME ?? 'app', version: process.env.npm_package_version, env: process.env.NODE_ENV, }, redact: ['req.headers.authorization', 'body.password'], // never log secrets }); // Child loggers — inherit parent config, add context const reqLogger = logger.child({ requestId: 'abc-123', userId: 42 }); reqLogger.info({ action: 'order.created', orderId: 99 }, 'Order created'); // → {"level":30,"time":...,"service":"app","requestId":"abc-123","userId":42,"action":"order.created","orderId":99,"msg":"Order created"} // HTTP request logging middleware (npm install pino-http) const pinoHttp = require('pino-http'); app.use(pinoHttp({ logger })); // Adds req.log — use it inside route handlers to get the request-scoped logger // Log levels: trace/debug/info/warn/error/fatal // Only enable trace/debug in staging — in production set LOG_LEVEL=warn to reduce noise

Metrics with prom-client

Logs tell you what happened. Metrics tell you the rate, latency distribution, and error ratio across all requests in real time. Prometheus scrapes a /metrics endpoint; Grafana visualises it.

// npm install prom-client const promClient = require('prom-client'); // Collect default Node.js metrics (event-loop lag, GC, heap, handles) promClient.collectDefaultMetrics({ prefix: 'myapp_' }); // Custom metrics — counters, gauges, histograms, summaries const httpRequests = new promClient.Counter({ name: 'myapp_http_requests_total', help: 'Total HTTP requests', labelNames: ['method', 'route', 'status'], }); const httpDuration = new promClient.Histogram({ name: 'myapp_http_duration_seconds', help: 'HTTP request latency in seconds', labelNames: ['method', 'route'], buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 5], }); // Middleware to instrument every request app.use((req, res, next) => { const end = httpDuration.startTimer({ method: req.method, route: req.path }); res.on('finish', () => { end(); httpRequests.inc({ method: req.method, route: req.path, status: res.statusCode }); }); next(); }); // Metrics endpoint — scraped by Prometheus every 15s app.get('/metrics', async (_req, res) => { res.set('Content-Type', promClient.register.contentType()); res.end(await promClient.register.metrics()); });

Zero-Downtime Deployment

StrategyHow it worksRollbackCost
Rolling Replace instances one (or a few) at a time. Traffic is load-balanced across old and new instances during the rollout. Roll forward or restart previous image No extra infra — same instance count
Blue/Green Spin up the new version alongside the old (blue=old, green=new). Switch load balancer to green when smoke tests pass. Blue stays ready as instant rollback. Instant — flip load balancer back Double the infra during switch
Canary Route a small percentage of traffic (e.g. 5%) to the new version. Monitor error rate and latency. Gradually increase if healthy. Drain canary traffic back to stable Low — small canary pool only
Rolling deployment — Kubernetes default v1 v1 v1 v1 v1 → start replacing v2 v1 v1 v1 v1 → 1 of 5 replaced v2 v2 v1 v1 v1 → 2 of 5 replaced v2 v2 v2 v1 v1 → 3 of 5 — mixed traffic (must be backwards-compatible!) v2 v2 v2 v2 v1 v2 v2 v2 v2 v2 → done During the rollout, v1 and v2 handle requests simultaneously. DB schema changes must be backwards-compatible (add-before-remove, not rename).

Graceful Shutdown in Production

// server.js — the complete production shutdown sequence const server = app.listen(port, () => logger.info(`Listening on :${port}`)); let shuttingDown = false; async function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; logger.info({ signal }, 'Shutdown initiated'); // 1. Stop accepting new connections server.close(async () => { try { // 2. Drain in-flight requests (server.close() waits for this) // 3. Close backing service connections cleanly await Promise.allSettled([ db.end(), redis.quit(), worker.close(), // BullMQ worker — wait for active job ]); logger.info('Shutdown complete'); process.exit(0); } catch (err) { logger.error(err, 'Error during shutdown'); process.exit(1); } }); // 4. Force exit if drain takes too long (Kubernetes sends SIGKILL after 30s anyway) setTimeout(() => { logger.error('Forced exit after drain timeout'); process.exit(1); }, 25_000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // Global error handlers — log and exit; let the orchestrator restart the process process.on('uncaughtException', (err) => { logger.fatal(err, 'Uncaught exception'); process.exit(1); }); process.on('unhandledRejection', (err) => { logger.fatal(err, 'Unhandled rejection'); process.exit(1); });

Security Hardening Checklist

Container Security

Run as a non-root user (Dockerfile USER appuser). Use a minimal base image (alpine). Scan images with docker scout cves or Trivy before shipping. Never bake secrets into the image — mount them as environment variables or via a secrets manager at runtime.

Dependency Security

npm ci (not npm install) in Docker builds — exact lockfile, no drift. npm audit --audit-level=high in CI; fail the build on high/critical. Pin your base Node image to a specific digest, not just a tag.

Secrets Management

Never commit .env files. In production: use your platform's secrets store (AWS Secrets Manager, Kubernetes Secrets, HashiCorp Vault). Inject at runtime as environment variables. Validate at startup — fail fast with a clear error if a required secret is missing.

Network Security

Only expose the port the app listens on. Internal services (Redis, DB) should not be reachable from outside the cluster. Use TLS for all service-to-service communication. Helmet middleware for HTTP security headers. Rate limiting at the gateway or per-route.

Incident Response

When something goes wrong in production, the first five minutes determine whether it's a 10-minute incident or a 4-hour outage. Having the process internalised before the alarm goes off is what separates a calm response from a panic.

// Useful commands during an incident # 1. What's the error rate right now? # Check your metrics dashboard (Grafana/Datadog) — http_requests_total{status=~"5.."}} # 2. Where are the errors coming from? docker logs myapp --tail=100 --follow # or: kubectl logs -l app=myapp --tail=100 -f # 3. Is the process alive and healthy? curl -s http://localhost:3000/health/ready | jq . # 4. How much memory/CPU is the process using? docker stats myapp # or: kubectl top pod -l app=myapp # 5. Is the database reachable? # Check /health/ready — it runs your db check # 6. Take a heap snapshot without restarting (if you wired up SIGUSR2 → v8.writeHeapSnapshot) kill -USR2 $(pgrep -f "node src/server.js") # 7. Rollback immediately if new deploy is the cause kubectl rollout undo deployment/myapp # or docker service update --rollback myapp

Production Readiness Checklist

  • All config comes from environment variables (no hardcoded strings)
  • App binds to process.env.PORT
  • SIGTERM handler drains in-flight requests before exit
  • uncaughtException and unhandledRejection log and exit (letting orchestrator restart)
  • Structured JSON logs to stdout — no file appenders
  • LOG_LEVEL configurable via env; secrets redacted
  • GET /health/live and GET /health/ready endpoints present
  • Dockerfile uses multi-stage build and non-root user
  • npm ci (not npm install) in Dockerfile
  • .env files in .dockerignore and .gitignore
  • npm audit passes at --audit-level=high in CI
  • Prometheus /metrics endpoint exposed (or equivalent)
  • Default prom-client metrics collected (event loop, GC, heap)
  • DB connection pool sized for expected concurrency
  • All external service calls have timeouts set
  • Circuit breakers on critical downstream dependencies
  • BullMQ workers handle SIGTERM (worker.close() in shutdown sequence)
  • DB schema migrations run separately from app startup
  • Rolling/canary deployment — app handles mixed v1/v2 traffic gracefully
  • Runbook documented: how to check status, rollback, restart, take heap snapshot

💡 The Node.js Maturity Ladder

Looking back across all three courses: you started with the event loop and callbacks, moved through async patterns, streams, HTTP servers, and testing in the Fundamentals course; added child processes, worker threads, database integration, environment config, process management, and PM2 in Intermediate; and in this Advanced course covered profiling, memory management, WebSockets, security, caching, message queues, microservices, and production deployment.

The thread running through all of it: Node's single-threaded event loop is both its superpower and its constraint. Keep work off the main thread, never block, always handle errors, always set timeouts, always clean up — and it'll handle tens of thousands of concurrent connections on modest hardware without complaint.

Coding Challenges

Challenge 1 — Production Server Bootstrap

Write a createServer(app, options) factory that wires up everything a production Node.js server needs at startup: validates required env vars (fail-fast with a clear error listing every missing variable), configures Pino structured logging with request middleware, mounts /health/live and /health/ready routes, mounts a Prometheus /metrics route, installs SIGTERM/SIGINT graceful shutdown (30s drain timeout, then force exit), and handles uncaughtException/unhandledRejection by logging fatal and exiting. Return { server, logger, shutdown } so callers can reference them. Write a test that verifies the missing-env-var validation throws before the server starts.

View sample solution ↗

Challenge 2 — Dockerfile Audit Tool

Write a Node.js script audit-dockerfile.js <path> that reads a Dockerfile and checks for the following best practices, printing a PASS/FAIL/WARN result for each: (1) uses a specific image tag or digest (not :latest), (2) uses multi-stage build (FROM … AS), (3) has a non-root USER instruction, (4) uses npm ci not npm install, (5) has a HEALTHCHECK instruction, (6) CMD uses array form not shell form, (7) .env is not COPYed (check for COPY .env patterns). Exit code 0 if no FAILs, 1 if any FAILs. Test it against a deliberately broken Dockerfile and a clean one.

View sample solution ↗

Challenge 3 — Observability Middleware Stack

Build an Express middleware factory createObservability({ serviceName, logLevel }) that returns an array of middleware providing: Pino HTTP request/response logging (with requestId generated via crypto.randomUUID() and attached to all logs in the request), Prometheus request counter and duration histogram (labelled by method and route, using req.route.path not req.path to avoid high-cardinality label explosion), and an error-catching middleware that logs at error level with the requestId and returns a sanitised JSON error (no stack traces to the client). Write integration tests using supertest that verify: requestId appears in response headers, a slow route records a duration above a threshold, and a thrown error returns 500 with no stack trace in the body.

View sample solution ↗

Node.js Advanced — Complete

8 chapters · Performance Profiling · Memory Management · WebSockets · Security · Caching · Message Queues · Microservices · Production & Deployment