Kodokon kodokon.com

Going to production: process manager, Docker, cluster

Ship to production: NODE_ENV, process manager, a correct Docker image, and multi-core scaling with cluster.

10 min · 3 questions

Open this lesson in Kodokon

NODE_ENV is merely a library convention: the Node core almost entirely ignores it. But Express, among others, uses it to cache views and hide stack traces; some frameworks are several times faster with NODE_ENV=production. On the dependencies side, npm ci --omit=dev installs exactly the lockfile, without dev dependencies - reproducible and faster than npm install. Since Node 20.6, --env-file loads a .env file natively, with no dependency.

BASH
NODE_ENV=production node server.js

npm ci --omit=dev

node --env-file=.env server.js
The three basic commands for going to production.

A Node process always ends up dying: memory overrun, bug, exception. The role of the process manager is to restart it. Two schools: on a bare machine, pm2 (or systemd) supervises, restarts, and reloads without downtime; in a container, it's the orchestrator (Kubernetes, ECS) that plays this role - one process per container, and pm2 becomes useless, even harmful, because it hides crashes from the orchestrator.

BASH
npm install -g pm2
pm2 start server.js -i max --name api
pm2 reload api
pm2 logs api --lines 100
pm2: -i max forks one worker per core, reload with no downtime.

Two Docker pitfalls specific to Node. One: the image should run as the unprivileged node user, provided by the official images. Two: Node isn't designed to be PID 1 - it doesn't reap zombie processes; launch the container with docker run --init (or init: true in Compose) to insert a minimal init. Finally, write CMD in exec form: the shell form inserts /bin/sh, which doesn't forward SIGTERM - the graceful shutdown from the previous lesson would never trigger.

BASH
FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:20-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Multi-stage Dockerfile: node user, CMD in exec form.

A single Node process uses only one core. The cluster module creates one worker process per core: the primary listens on the port and distributes connections to the workers in round-robin (the default behavior, except on Windows). Each worker has its own memory and its own event loop: no shared state - sessions and caches must live in Redis or an equivalent. For pure computation, prefer worker_threads, which shares memory via SharedArrayBuffer.

JAVASCRIPT
import cluster from "node:cluster";
import { createServer } from "node:http";
import { availableParallelism } from "node:os";

if (cluster.isPrimary) {
  const count = availableParallelism();
  for (let i = 0; i < count; i += 1) cluster.fork();
  cluster.on("exit", (worker) => {
    console.log(`worker ${worker.process.pid} down`);
    cluster.fork();
  });
} else {
  createServer((req, res) => {
    res.end(`pid ${process.pid}`);
  }).listen(3000);
}
One worker per core, automatically restarted if it dies.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What does NODE_ENV=production actually change?
    • Node.js enables a more aggressive JIT compiler
    • Libraries like Express enable their optimizations (view caching, non-verbose errors)
    • Node.js refuses to start without HTTPS
  2. Why prefer the exec form CMD ["node", "server.js"] in a Dockerfile?
    • The exec form starts faster
    • The exec form allows variable interpolation
    • The shell form goes through /bin/sh, which doesn't forward SIGTERM to the Node process
  3. With cluster and 8 workers, what happens to memory and application state?
    • Each worker is a separate process: memory multiplied by 8, no shared state
    • The workers share the same V8 heap
    • The primary periodically merges the workers' heaps