Production Deployment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on Node.js 26, and on 24 and 22 where the text says so. Node.js itself has no "production mode": the runtime behaves the same on a laptop and on a server, so everything in this page is about what surrounds the process: how it is configured, started, supervised, stopped, probed, fronted by a proxy, sized and observed. The APIs involved (process signals, process.constrainedMemory(), server.close(), process.stdout) are all Stability 2 (Stable) or, for the two memory functions, have been Stable since v24.0.0 and v22.16.0. The running scenario is the Bookshelf application described in Introduction & Architecture; this page turns its HTTP server from HTTP Server Fundamentals into something an operator can run unattended.

The stability and version map

Table 1. APIs and options covered on this page
API or option Stability (Node.js 26 docs) Version notes

process.env, process.on('SIGTERM'), process.exitCode

2 Stable

SIGTERM and SIGINT have default handlers on non-Windows platforms that exit with code 128 plus the signal number; installing a listener removes that default. SIGTERM is not supported on Windows (it can be listened for, but the platform does not deliver it the way POSIX does).

server.close(), server.closeIdleConnections(), server.closeAllConnections()

2 Stable

closeAllConnections() and closeIdleConnections() were added in v18.2.0. Since v19.0.0 http.Server.close() closes idle connections itself, so calling closeIdleConnections() next to it is no longer needed.

process.constrainedMemory(), process.availableMemory()

2 Stable

Stable since v24.0.0 and v22.16.0 (they were Experimental before). constrainedMemory() was added in v19.6.0 and v18.15.0 and its return value was aligned with libuv in v22.0.0 and v20.13.0; availableMemory() was added in v22.0.0 and v20.13.0.

--max-old-space-size, --max-old-space-size-percentage

Documented CLI options

The percentage flag takes precedence over the absolute one. The CLI page gives no "added in" version for it; it was observed working on 26.10.0 and 22.23.3, including through NODE_OPTIONS.

--env-file, process.loadEnvFile()

2 Stable

No longer experimental in v24.10.0 and v22.21.0. See CLI Options, Environment & Config File.

process.stdout, process.stderr

2 Stable

Files and TTYs are synchronous on POSIX; pipes and sockets are asynchronous on POSIX (synchronous on Windows). The details matter for logging (see Logging to stdout).

Development versus production, and NODE_ENV

The official Learn article states the starting point plainly: there is no difference between development and production in Node.js, and no special setting makes the runtime work "in production". What does differ is the libraries. A number of packages in the npm registry read NODE_ENV and default to a development behaviour when it is missing (verbose error pages, template caches switched off, extra checks), so the article’s recommendation is to always run with NODE_ENV=production set. The same article also explains why NODE_ENV is considered an antipattern when it is used as a general feature switch: code that branches on development, production and staging makes the environments differ in behaviour, so a test that passes under one value can fail under another and a staging run no longer proves anything about production. The practical rule is to set NODE_ENV to production everywhere the application is deployed, including staging, and to express every other difference as explicit configuration (a URL, a pool size, a log level) read from the environment, in the spirit of the twelve-factor methodology that the article links to.

Configuration is therefore a validated, typed object built once at start-up from process.env, with secrets supplied by the platform and never written into the source or the image. The Bookshelf configuration module below rejects an unexpected NODE_ENV and reads every other setting from the environment.

src/config.js: configuration read once from the environment
import process from 'node:process';

const env = process.env.NODE_ENV ?? 'development';
if (!['development', 'production', 'test'].includes(env)) {
  throw new Error(`Unsupported NODE_ENV: ${env}`);
}

export const config = {
  env,
  port: Number(process.env.PORT ?? 3000),
  host: process.env.HOST ?? '127.0.0.1',
  drainDelayMs: Number(process.env.DRAIN_DELAY_MS ?? 0), // time for the load balancer to notice /readyz
  shutdownTimeoutMs: Number(process.env.SHUTDOWN_TIMEOUT_MS ?? 10_000),
};

Running a script that prints the object, first with NODE_ENV=production, then with an unsupported value, and finally with a file passed through --env-file (each run used a scratch copy of the module), gave this on Node.js 26 and the same result on 22:

Output of the three runs (abridged)
$ NODE_ENV=production node src/print-config.js
{ env: 'production', port: 3000, host: '127.0.0.1', drainDelayMs: 0, shutdownTimeoutMs: 10000 }

$ NODE_ENV=prod node src/print-config.js
Error: Unsupported NODE_ENV: prod

$ NODE_ENV=production node --env-file=.env.prod src/print-config.js   # .env.prod contains PORT=8080
{ env: 'production', port: 8080, host: '127.0.0.1', drainDelayMs: 0, shutdownTimeoutMs: 10000 }

The differences that really separate a development run from a production run are the ones that are not about Node.js at all.

Table 2. What usually differs between the two
Concern Development Production

Dependencies

npm install, dev dependencies present, a watcher such as node --watch.

npm ci --omit=dev from a committed lock file; no watcher; see npm & Package Management.

Process lifetime

Started by hand, restarted by the developer.

Started and restarted by a supervisor or an orchestrator, stopped with a signal.

Configuration and secrets

A local env file passed with --env-file.

Variables injected by the platform (secrets store, orchestrator); no env file in the image.

Network edge

localhost, plain HTTP, any port.

A reverse proxy or load balancer terminates TLS and routes traffic; the app listens on a private address.

Observability

Pretty console output.

One structured JSON line per event on stdout, collected by the platform; metrics and traces exported.

Failure policy

Read the stack trace and fix it.

Crash on programmer errors, restart automatically, alert on restart loops; see Errors & Error Handling.

Process managers versus orchestrators

A Node.js process that exits is gone. Something outside the process must notice and start another one, and that something is a supervisor. Which one you use depends on where the application runs, and the choice decides who sends the stop signal, how long the process gets to drain, and who restarts it.

Table 3. Supervisors compared
Option What it does Stop behaviour Typical use

systemd

The init system of most Linux distributions. A unit file declares the user, environment, restart policy and sandboxing.

Sends KillSignal (default SIGTERM), waits TimeoutStopSec, then SIGKILL.

A single VM or bare-metal host, no container platform.

PM2

A Node-specific process manager: restarts, log files, a start-up script generator, and an optional cluster mode that spreads one app over several workers.

Sends SIGINT first (its documented default, which this page’s handler also catches), waits kill_timeout, then SIGKILL.

A host without systemd conventions or an orchestrator, or when its cluster mode and max_memory_restart are wanted.

Container runtime (Docker, containerd)

Runs the image and applies --restart policies; the application is PID 1 of its container.

docker stop sends SIGTERM (or the image’s STOPSIGNAL), waits ten seconds by default, then SIGKILL.

Local runs and small deployments; see Docker & Containers.

Orchestrator (Kubernetes, Nomad, ECS)

Schedules replicas across machines, replaces failed ones, rolls out versions, and decides readiness from probes.

Marks the Pod terminating, removes it from Service endpoints, sends SIGTERM, waits terminationGracePeriodSeconds, then SIGKILL.

Anything that needs several replicas, rolling deploys or autoscaling; see Kubernetes Health Probes & Lifecycle.

A systemd unit is short. This one runs Bookshelf as an unprivileged user, reads secrets from a root-owned environment file, restarts on failure, and uses the same percentage-based heap limit explained in Memory sizing in containers.

systemd/bookshelf.service
[Unit]
Description=Bookshelf API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=bookshelf
Group=bookshelf
WorkingDirectory=/opt/bookshelf
Environment=NODE_ENV=production
EnvironmentFile=/etc/bookshelf/bookshelf.env
ExecStart=/usr/bin/node --max-old-space-size-percentage=75 src/server.js
KillSignal=SIGTERM
TimeoutStopSec=15
Restart=on-failure
RestartSec=2
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

The unit was checked with systemd-analyze verify inside a Debian container (exit status 0, no warnings); it was not started as a service because the development machine is macOS, so the restart and sandboxing behaviour is taken from the systemd documentation and not observed.

PM2 is configured with an ecosystem file. The key production settings are kill_timeout (it must be larger than the application’s own shutdown timeout, otherwise PM2 kills a process that was still draining) and max_memory_restart.

ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'bookshelf',
      script: './src/server.js',
      interpreter: 'node',
      exec_mode: 'fork',          // 'cluster' with instances: 'max' adds the cluster module
      kill_timeout: 12000,        // ms between SIGINT and SIGKILL; keep above SHUTDOWN_TIMEOUT_MS
      max_memory_restart: '400M', // restart on a leak instead of waiting for the OOM killer
      env: {
        NODE_ENV: 'production',
        HOST: '127.0.0.1',
        PORT: 38101,
        SHUTDOWN_TIMEOUT_MS: 10000,
      },
    },
  ],
};

The file uses the .cjs extension because PM2 loads it with require() and the Bookshelf package is "type": "module". Run with PM2 7.0.4 (pm2-runtime, in the foreground) on Node.js 26, the app answered /readyz with ready; when PM2 stopped it, the process logged shutdown started with "signal":"SIGINT", closed the server and exited cleanly, confirming that a handler for SIGTERM alone would not have been enough under PM2. (The foreground runner then printed a stream of "failed to kill - retrying" lines while it waited for its bookkeeping to catch up; that was cosmetic in this run, and is an observation about this PM2 version, not documented behaviour.) The cluster mode and its trade-offs are covered in Cluster & Scaling.

PM2 and systemd solve the same problem on one host. Once there are several replicas, the orchestrator already does restarts, replacement and traffic management and a process manager inside the container only hides the application’s real exit status from it, so the usual advice is one Node.js process per container, with the orchestrator providing the supervision. Node-level parallelism inside a container is then a deliberate choice, covered in Cluster & Scaling.

Graceful shutdown

Every platform above stops a process the same way: it sends a signal, waits a grace period, and then kills it. A graceful shutdown uses that grace period to finish the work in flight instead of cutting connections. The sequence is always the same.

  1. Receive SIGTERM (or SIGINT for Ctrl+C and PM2) and remember that shutdown has begun.

  2. Stop being routed to: make readiness fail, and when a proxy or Service is in front, wait a moment for it to notice.

  3. Stop accepting new connections with server.close().

  4. Drain: let in-flight requests, jobs and streams finish; idle keep-alive connections are closed by close() itself.

  5. Close resources in reverse order of creation: database pools, queue consumers, caches, file handles, telemetry exporters.

  6. Exit, preferably by letting the event loop empty, with a hard timeout as a safety net.

timeline title Graceful shutdown of one replica (default budget 30 s) section Before the signal t = 0 s : Replica serves traffic : readiness returns 200 section Signal received t = 0 s : Orchestrator sends SIGTERM : handler sets shuttingDown : readiness starts returning 503 t = 0 to 5 s : Drain delay : proxy and Service stop routing new requests here section Draining t = 5 s : server.close stops accepting connections : idle keep-alive sockets are closed t = 5 to 7 s : In-flight requests finish : responses are written normally section Cleanup t = 7 s : Close the database pool and queue consumers : flush logs and traces t = 7.1 s : Event loop is empty : process exits with code 0 section Safety net t = 20 s : Application timeout fires : exits with code 1 if still running t = 30 s : terminationGracePeriodSeconds expires : SIGKILL
Figure 1. Timeline of a graceful shutdown

The Bookshelf server implements exactly that sequence. Three details deserve attention. First, server.close() stops the listening socket immediately but only emits 'close' once every connection has ended, so the code awaits the event rather than the callback of a neighbouring call. Second, the readiness endpoint reports the shutdown state, and a DRAIN_DELAY_MS pause lets the proxy observe it before the socket closes (with a delay of zero the port is refused immediately, which is fine behind a client that retries and wrong behind a load balancer that does not). Third, the safety timer is unref()-ed so it never keeps a healthy process alive, and it is set below the supervisor’s own kill timeout so the application reports the failure before the platform does.

src/server.js: liveness, readiness, JSON logs and graceful shutdown
import http from 'node:http';
import process from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { config } from './config.js';

// One JSON object per line on stdout; the platform collects and ships it.
const log = (level, msg, extra = {}) =>
  process.stdout.write(`${JSON.stringify({ time: new Date().toISOString(), level, msg, ...extra })}\n`);

let shuttingDown = false;

const server = http.createServer(async (req, res) => {
  const { pathname } = new URL(req.url, 'http://localhost');
  if (pathname === '/livez') {            // liveness: is the process able to answer?
    res.writeHead(200, { 'Content-Type': 'text/plain' }).end('alive\n');
  } else if (pathname === '/readyz') {    // readiness: should traffic be sent here?
    res.writeHead(shuttingDown ? 503 : 200, { 'Content-Type': 'text/plain' })
      .end(shuttingDown ? 'draining\n' : 'ready\n');
  } else if (pathname === '/slow') {      // simulates an in-flight request
    await sleep(2000);
    res.writeHead(200, { 'Content-Type': 'text/plain' }).end('slow done\n');
  } else {
    res.writeHead(404).end();
  }
});

async function shutdown(signal) {
  if (shuttingDown) return;               // ignore repeated signals
  shuttingDown = true;
  log('info', 'shutdown started', { signal });

  // Safety net: never let a stuck connection keep the process alive forever.
  const timer = setTimeout(() => {
    log('error', 'shutdown timed out, forcing exit');
    process.exit(1);
  }, config.shutdownTimeoutMs);
  timer.unref();

  // 1. Fail readiness first and give the proxy or Service time to stop routing here.
  await sleep(config.drainDelayMs);
  // 2. Stop accepting new connections; close() also closes idle keep-alive sockets.
  const closed = new Promise((resolve) => server.once('close', resolve));
  server.close(() => log('info', 'http server closed'));
  // 3. Drain: in-flight requests finish and their sockets close; wait for the 'close' event.
  await closed;
  // 4. Close other resources (database pools, queues...) here.
  await sleep(100);
  log('info', 'resources closed');
  process.exitCode = 0;                   // let the loop drain instead of process.exit()
}

for (const signal of ['SIGTERM', 'SIGINT']) {
  process.on(signal, () => shutdown(signal));
}

server.listen(config.port, config.host, () => {
  log('info', 'listening', { port: server.address().port, env: config.env, pid: process.pid });
});

The server was started with NODE_ENV=production DRAIN_DELAY_MS=1000 PORT=0, a /slow request (two seconds of work) was issued, SIGTERM was sent half a second later, and /readyz was polled during the drain delay. The same script gave the same result on Node.js 26, 24 and 22.

Observed behaviour (identical on Node.js 26, 24 and 22)
livez 200
readyz during drain: 503        (1)
slow done                       (2)
exit code 0                     (3)
{"time":"…","level":"info","msg":"listening","port":52993,"env":"production","pid":92764}
{"time":"…","level":"info","msg":"shutdown started","signal":"SIGTERM"}
{"time":"…","level":"info","msg":"http server closed"}
{"time":"…","level":"info","msg":"resources closed"}
1 Readiness fails while the socket is still open, so a proxy can stop routing before connections are refused.
2 The in-flight request completed after SIGTERM: it was drained, not cut.
3 The process left by emptying its event loop, with status 0.

Two more runs show the edges. With DRAIN_DELAY_MS=0 the /readyz poll after SIGTERM was refused (status 000 in curl), because the listening socket is closed at once. With SHUTDOWN_TIMEOUT_MS=500 and the same slow request, the safety timer fired, logged shutdown timed out, forcing exit and the process exited with status 1 while the slow request was still running, which is the intended trade: a bounded shutdown is better than a hung one.

Signals, PID 1 and exit codes

Signal delivery has a few rules that decide whether the code above ever runs.

  • The handler replaces the default. Without a listener, SIGTERM and SIGINT terminate the process with exit code 128 plus the signal number (143 and 130). Adding a listener removes that default, so a handler that never exits leaves a process that ignores the signal until the supervisor sends SIGKILL (exit code 137), which cannot be caught.

  • Signal events are asynchronous. Node.js can exit before the listener runs when the event loop would otherwise be empty, which is rarely a problem for a server that is listening.

  • PID 1 is special in a container. The kernel does not apply default signal actions to PID 1, so a process that is PID 1 and has no handler ignores SIGTERM. Run in a container with node:26-slim, a script with no handler lasted until docker stop -t 3 escalated and exited with status 137; the same script run with docker run --init (an init process that forwards signals) exited with status 143; and the Bookshelf server, which has a handler, exited with status 0 and the log lines shown above, with "pid":1. Handle the signal in the application, and use --init or a minimal init such as tini for images where child processes are spawned; see Docker & Containers.

  • Windows. SIGTERM is not delivered the way POSIX delivers it, so graceful shutdown on Windows relies on other mechanisms (for example SIGINT from a console or an IPC message from the parent). Treat Windows as a development platform for this pattern.

  • Wrapper scripts and npm start. A shell or npm between the supervisor and node may not forward the signal. Use exec node … in shell scripts, or invoke node directly as the container command.

  • Exit by emptying the loop. Setting process.exitCode and letting the loop drain runs pending 'close' handlers and flushes stdout; calling process.exit() skips whatever is still pending. Keep process.exit() for the timeout path. See Process, Signals & Exit Codes.

What to drain and in what order

An HTTP server is only one of the things a replica owns. Close them in the reverse of the order they were opened so nothing uses a resource that has already gone.

Table 4. Typical shutdown order
Step Resource Notes

1

Readiness flag, then the HTTP server

Fail readiness, wait for the proxy, then server.close(). Long-lived connections (Server-Sent Events, WebSockets) are not idle and keep 'close' from firing; end them with an application message and closeAllConnections() when the timeout is near. See WebSockets & Server-Sent Events.

2

Queue and job consumers

Stop taking new messages, finish the current one, acknowledge it. See Background Jobs, Scheduling & Queues.

3

Database pools and clients

Await the client’s close() or end() so queued queries complete. See Databases from Node.js.

4

Telemetry and loggers

Flush last, so shutdown itself is observable.

5

Exit

Set process.exitCode, let the loop empty, and rely on the unref-ed timer only when something hangs.

Programmer errors are handled the opposite way: an uncaughtException or an unhandled rejection means the process is in an unknown state, so the correct production behaviour is to log, try a fast bounded cleanup and exit non-zero, and let the supervisor start a clean replica. Node.js has treated unhandled rejections as errors that throw by default since v15.0.0; the details, and why process.on('uncaughtException') must not be used to keep running, are in Errors & Error Handling. NestJS wraps this sequence in lifecycle hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown), described in NestJS Lifecycle Events & Shutdown, and in NestJS Deployment.

Health and readiness endpoints

A supervisor that only checks that a process exists cannot tell a healthy server from one that is wedged, and an orchestrator needs to know whether to send it traffic. Platforms therefore probe HTTP endpoints, and Kubernetes distinguishes three kinds, covered in depth in Kubernetes Health Probes & Lifecycle.

Table 5. Three probes, three questions
Probe Question it answers What a failure causes

Liveness (/livez)

Is the process able to respond at all?

The container is restarted. Keep it trivial: it must not call the database, or a database outage restarts every replica.

Readiness (/readyz)

Should this replica receive traffic right now?

The replica is removed from the load balancer or Service endpoints; the process keeps running. It may check dependencies the app cannot work without, and it must fail during shutdown.

Startup

Has the slow initial start finished?

Liveness and readiness are held back until it succeeds, so a long start-up is not mistaken for a hang.

The Bookshelf server above exposes /livez and /readyz on the same port as the API, which keeps the example short. In a real service, consider a separate internal port for probes and metrics, so that a proxy rule or an authentication layer on the public port can never accidentally block (or expose) them. Since Node.js handles requests on one thread, a health endpoint also doubles as an event-loop liveness check: if the loop is blocked by a CPU-bound task, the probe times out, which is usually the behaviour you want (see Don’t Block the Event Loop).

The Kubernetes side of the contract is a handful of fields in the Deployment: the probes, the grace period (which must exceed the application’s drain delay plus its shutdown timeout) and the memory limit discussed in Memory sizing in containers.

deployment.yaml (excerpt of a Deployment for Bookshelf)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf
spec:
  replicas: 2
  selector:
    matchLabels: { app: bookshelf }
  template:
    metadata:
      labels: { app: bookshelf }
    spec:
      terminationGracePeriodSeconds: 30     # SIGKILL after this; keep above SHUTDOWN_TIMEOUT_MS
      containers:
        - name: api
          image: registry.example.com/bookshelf:1.0.0
          env:
            - { name: NODE_ENV, value: production }
            - { name: HOST, value: 0.0.0.0 }
            - { name: PORT, value: "3000" }
            - { name: DRAIN_DELAY_MS, value: "5000" }
            - { name: SHUTDOWN_TIMEOUT_MS, value: "20000" }
            - { name: NODE_OPTIONS, value: --max-old-space-size-percentage=75 }
          resources:
            requests: { memory: 256Mi, cpu: 100m }
            limits: { memory: 256Mi }
          startupProbe:
            httpGet: { path: /livez, port: 3000 }
            failureThreshold: 30
            periodSeconds: 1
          livenessProbe:
            httpGet: { path: /livez, port: 3000 }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /readyz, port: 3000 }
            periodSeconds: 5

The manifest was parsed as YAML and the NODE_OPTIONS value was accepted by Node.js 26 (the process started normally with it), but it was not applied to a cluster, since none was available here; for a tested rollout see Kubernetes Deployment Strategies.

Reverse proxies and TLS termination

Node.js can serve HTTPS itself (see HTTPS, TLS & HTTP/2), but most production deployments put a reverse proxy or load balancer in front: nginx, HAProxy, Caddy, a cloud load balancer or a Kubernetes Ingress. The proxy terminates TLS (so certificates are issued, renewed and rotated in one place), absorbs slow clients, can serve static files, buffers and compresses, applies rate limits and keeps routing, access control and the public port out of the application. Its Node.js-facing consequences are three.

  • The peer address is the proxy’s. req.socket.remoteAddress is the proxy; the client address arrives in the X-Forwarded-For header, and the original scheme in X-Forwarded-Proto. Node’s node:http has no "trust proxy" setting; trust these headers only when they come from your own proxy (frameworks such as Express expose a trust proxy option for this, covered in Web Frameworks: Express & Fastify).

  • Keep-alive between the two. Reuse upstream connections, and keep the server’s keepAliveTimeout longer than the proxy’s idle timeout, or the proxy will occasionally send a request on a connection that Node.js has just closed and report a bad-gateway error (HTTP 502). See Server Timeouts, Limits & Hardening.

  • Timeouts line up. The proxy’s read timeout, the application’s request timeout and the orchestrator’s grace period are three clocks that must agree.

nginx/bookshelf.conf: TLS termination in front of Bookshelf
upstream bookshelf {
    server host.docker.internal:38102;   # in production: the app's private address
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name bookshelf.example.com;

    ssl_certificate     /etc/nginx/certs/tls.crt;   # issued by your CA; never commit the key
    ssl_certificate_key /etc/nginx/certs/tls.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    location / {
        proxy_pass         http://bookshelf;
        proxy_http_version 1.1;
        proxy_set_header   Connection "";                       # enables upstream keep-alive
        proxy_set_header   Host              $host;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 30s;
    }
}

The configuration was checked with nginx -t and run for real with the nginx:alpine image, using a throwaway self-signed certificate generated in the scratch directory (not shown, and never to be reused), in front of a small Node.js 26 server that echoes what it receives:

src/proxy-headers.js: what the application sees behind the proxy
import http from 'node:http';

http.createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({
    remote: req.socket.remoteAddress,          // the proxy, not the client
    forwardedFor: req.headers['x-forwarded-for'],
    forwardedProto: req.headers['x-forwarded-proto'],
    host: req.headers.host,
  }) + '\n');
}).listen(38102, '0.0.0.0');

A request such as curl -k --resolve bookshelf.example.com:38443:127.0.0.1 https://bookshelf.example.com:38443/ returned the following, showing that TLS was terminated at nginx and the application received plain HTTP with the original scheme and host in headers (the addresses are those of the Docker Desktop network on the test machine).

Response from behind the proxy
{"remote":"127.0.0.1","forwardedFor":"172.17.0.1","forwardedProto":"https","host":"bookshelf.example.com"}

Certificates, keys and .env files stay outside the repository and the image; mount or inject them at run time.

Memory sizing in containers

A container memory limit is enforced by the kernel’s cgroup: a process that exceeds it is killed (out-of-memory, exit status 137) with no chance to shut down. That is different from a V8 heap limit, which produces a JavaScript "heap out of memory" fatal error. The two must be sized together, because a Node.js process uses more than its heap: thread stacks, Buffer and ArrayBuffer memory, native add-ons, the code space and the runtime itself all live outside the old space that --max-old-space-size limits.

Node.js exposes what it sees through process.constrainedMemory() (the limit imposed by the operating system, in bytes, or 0 when there is none or it is unknown), process.availableMemory() (the free memory still available to the process) and v8.getHeapStatistics().heap_size_limit.

src/memory.js: what does this process think it may use?
import process from 'node:process';
import v8 from 'node:v8';

const MiB = (bytes) => Math.round(bytes / 1024 / 1024);

console.log(JSON.stringify({
  node: process.version,
  constrainedMemoryMiB: MiB(process.constrainedMemory()), // cgroup limit, 0 when unknown
  availableMemoryMiB: MiB(process.availableMemory()),
  heapLimitMiB: MiB(v8.getHeapStatistics().heap_size_limit),
}));

The script was run on the host and in node:26-slim containers with different limits (docker run --memory=512m). All values are in MiB and were observed on a machine with more than 14 GiB free, so absolute host figures will differ on yours.

Table 6. Observed heap limits (Node.js 26.10.0 unless noted)
Run constrained available heap limit

Host, defaults

0

14202

4192

Host, --max-old-space-size=1536

0

14200

1632

Container --memory=512m, defaults

512

499

268

Container --memory=512m, --max-old-space-size-percentage=75

512

498

396

Container --memory=512m, --max-old-space-size=384

512

498

396

Container with no memory limit

17592186044416

7132

2144

Node.js 22.23.3 container --memory=512m, defaults

512

502

259

Node.js 22.23.3 container --memory=512m, --max-old-space-size-percentage=75

512

502

387

Reading the table: the runtime is aware of the cgroup limit (the default heap limit was roughly half the container limit on both 26 and 22), the percentage flag works on both lines, the heap limit is a little above the requested value because it also counts other V8 spaces, and an unconstrained container reports an enormous constrainedMemory() (the cgroup "no limit" value) rather than 0, so treat anything absurdly large as "no limit". The defaults are an observation, not a promise: they depend on the Node.js version, the V8 version and the platform.

Practical sizing rules:

  • Set the container limit first, then derive the heap from it. --max-old-space-size-percentage=75 (or an absolute --max-old-space-size in MiB) keeps the old space comfortably below the limit and leaves room for buffers, stacks and native memory. Pass it in NODE_OPTIONS or in the command line of the unit or container, as in the examples above.

  • Set the request equal to the limit for memory in Kubernetes. Memory is not compressible: a Pod that exceeds its limit is killed, and a Pod with a request lower than its real use can be evicted.

  • Watch RSS, not just the heap. process.memoryUsage().rss is what the cgroup counts. A growing gap between RSS and the heap indicates leaking buffers or native memory; see Memory & Leaks.

  • A smaller heap means more garbage collection. Measure under load before choosing the percentage, with the tools in Profiling & Performance.

  • Let the platform restart a leaking process (max_memory_restart in PM2, an orchestrator restart on the OOM kill) and treat the restart as an alert, not as a fix.

  • CPU is part of the same budget. os.availableParallelism() and the worker pools of Worker Threads should follow the container’s CPU limit.

Logging to stdout

A production process should not manage log files. The twelve-factor approach that the Learn article points to treats logs as an event stream that the application writes to standard output and the platform collects, routes and stores: systemd sends stdout to the journal, Docker and Kubernetes capture it through the container runtime, and PM2 writes it to files it rotates. The application then stays free of paths, rotation and retention, which are operator concerns.

Write one JSON object per line: a timestamp, a level, a message and the fields needed to correlate (request id, user id, trace id). The log function in the server above is the minimum; in a real service use a logging library built for that job (pino is the common choice in Node.js) and keep the same shape. Never log secrets, tokens, full request bodies or personal data, and keep high-volume debug output behind a level switch set from an environment variable. The console methods write to the process.stdout and process.stderr streams. According to the process documentation, files and terminals are written synchronously on POSIX systems, whereas pipes and sockets (the usual case under a container runtime or systemd) are written asynchronously on POSIX, so output written just before process.exit() can be lost on those destinations, which is another reason to exit by emptying the loop. Metrics, traces, diagnostic reports and diagnostics_channel are covered in Diagnostic Reports, Logging & Observability, and the container side in Docker Resources, Logging & Monitoring and Kubernetes Observability: Logging & Metrics.

The start-up lines of the server, as captured from stdout in the runs above, are valid JSON that any log pipeline can parse:

One log line, parsed from the observed output
{"time":"…","level":"info","msg":"shutdown started","signal":"SIGTERM"}

A production checklist

Table 7. Pre-flight checklist for a Node.js service
Area Check

Runtime

An LTS line (24 today, 26 from 2026-10-28; see Release Lines & LTS); a pinned image tag; NODE_ENV=production; npm ci --omit=dev.

Configuration

Validated at start-up; secrets injected, not baked in; no env file in the image.

Supervision

Exactly one supervisor; restart policy set; restart loops alert.

Shutdown

Handles SIGTERM (and SIGINT under PM2); readiness fails first; server.close(); resources closed; a timeout below the platform’s kill timeout; exit status checked.

Probes

Liveness is dependency-free; readiness reflects shutdown; startup probe for slow starts.

Edge

TLS terminated at the proxy; forwarded headers trusted only from it; keep-alive and timeouts aligned.

Memory

Container limit set; heap limit derived from it (--max-old-space-size-percentage); RSS monitored.

Logging

JSON to stdout; no secrets; levels from the environment.

Security

Non-root user; read-only file system where possible; see Security Best Practices and Permission Model.

What changed recently

Table 8. Changes relevant to deployment, with versions
Change Detail Versions

Memory functions are Stable

process.constrainedMemory() and process.availableMemory() moved from Experimental to Stable.

v24.0.0, v22.16.0

http.Server.close() closes idle connections

No separate closeIdleConnections() call is needed to reap keep-alive sockets.

v19.0.0

server[Symbol.asyncDispose]() no longer experimental

await using server = … calls close() on scope exit (needs a runtime that parses await using: 24 and later, not 22).

v24.2.0

--env-file and process.loadEnvFile() stable

Configuration from files no longer needs a package.

v24.10.0, v22.21.0

Built-in proxy support for outgoing requests

NODE_USE_ENV_PROXY and http.setGlobalProxyFromEnv() for clients behind a corporate proxy; Stability 1.1 Active development.

v24.5.0, v22.21.0 (setGlobalProxyFromEnv() v25.4.0, v24.14.0)

node --watch is stable

No nodemon for development; not a production tool.

Stable since v22.0.0 and v20.13.0

Corepack is no longer bundled

Install package-manager tooling explicitly in images.

From Node.js 25; Node.js 24 is the last line that bundles it

Annual release model

From Node.js 27 there is one major per year and every major becomes LTS.

Announced 2026-03-10; see Release Lines & LTS

Where the books are outdated

Both reference books approach production from the angle of their time, and several of their recommendations need updating for current Node.js. In Efficient Node.js by Samer Buna (O’Reilly, 2025) the scaling chapter covers PM2 and a restart-on-signal approach based on SIGUSR2 for zero-downtime reloads of a cluster; that pattern belongs to a world of long-lived hosts, whereas on an orchestrator a rolling update of whole replicas replaces it, and the shutdown code that matters is the SIGTERM handler above. Its baselines of Node.js 20 and earlier are end-of-life, and its npm install --production form is replaced by npm ci --omit=dev. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) reads configuration from a .env file through the dotenv package and shows a Dockerfile on an older base image; today --env-file and process.loadEnvFile() are built in, the file should not be shipped inside the image, and images should be on a current LTS slim tag and run as a non-root user. Neither book gives much space to graceful shutdown, readiness versus liveness probes, the interaction of the container memory limit with the V8 heap, or the process.constrainedMemory() and --max-old-space-size-percentage facilities, so this page fills those gaps. The version baselines and removals are collected in Upgrading & What’s Changed.

Where to go next

References