# Production Readiness

Course: [Node.js](https://www.devtutorials.ai/learn/nodejs) · Section: Quality and Production
Source: https://www.devtutorials.ai/learn/nodejs/quality-production/production-readiness

> What NODE_ENV changes in Node.js and libraries, validating configuration at start-up, process managers and restart strategies, one process per container, running as PID 1, health check endpoints, graceful shutdown with signals, server.close and closeIdleConnections, finishing in-flight work and closing resources and workers, shutdown timeouts, --max-http-header-size and the compile cache, structured JSON logging and log levels, diagnostics_channel, safe Node.js upgrades with a CI version matrix and deprecation flags, single executable applications and bundling, and a pre-production checklist.

## Production Readiness

**Production** = the place where real users use your app, as opposed to your own computer.

- In production, the app must start correctly, stay up and stop cleanly.
- It must tell you what is happening, through logs and health checks.
- Small habits make the difference: check settings, handle signals, log well.

**Simple lesson:** production-ready means it starts safely, runs steadily and stops cleanly.

### A Real-World Example

Think of the school bus before the morning trip.

- The driver checks fuel, tyres and brakes before starting. That is **config validation**.
- The transport office calls every hour: "All fine?" That is a **health check**.
- At the last stop, the driver waits for every child to get off before locking the bus. That is **graceful shutdown**.
- The driver fills in the logbook at every stop. That is **logging**.

## What You Will Learn

In this lesson, you will learn:

1. What `NODE_ENV` changes, and how to validate settings
2. Process managers, containers and PID 1
3. Health check endpoints
4. Graceful shutdown, step by step
5. Shutdown timeouts and forced exit
6. Tuning flags and the compile cache
7. Structured JSON logs, log levels and `diagnostics_channel`
8. Upgrading Node.js safely
9. Single executable apps, bundling, and a pre-production checklist

## Runtime Configuration

### NODE_ENV Behavior in Node.js and Libraries

**`NODE_ENV`** = an environment variable that says what mode the app runs in, usually `production` or `development`.

- **Node.js itself ignores it.** It does not make Node.js faster on its own.
- **Libraries read it.** Express, for example, caches views and hides error details when it is `production`.
- **Use real settings for real choices.** Read `LOG_LEVEL` or `CACHE_SECONDS`, rather than checking `NODE_ENV` everywhere.

### Validating Environment Configuration

- Read every setting once, at start-up, into one config object.
- Check that required settings exist and have the right type.
- If something is wrong, print a clear message and exit with code 1. This is **fail fast**.
- A crash at start-up is much better than a strange error at 2 AM.

Print the settings you loaded at start-up, except secrets. It makes support much easier.

- Delete the `DB_URL` line from `.env` and press ▶ Run. The app refuses to start, with a clear reason.

**Simple lesson:** read and check all settings at start-up, and stop at once if one is wrong.

<figure className="ngc-fig node-fig">
<svg viewBox="0 0 560 170" role="img" ariaLabelledby="fig-node-config" style={{ width: "100%", maxWidth: 560, height: "auto" }}>
<title id="fig-node-config">Settings come from defaults, then the .env file, then real environment variables. The last one wins</title>
<rect x="20" y="40" width="140" height="44" rx="8"  style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }}/><text x="90" y="60" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>defaults in code</text><text x="90" y="76" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 11 }}>PORT = 3000</text>
<rect x="20" y="92" width="140" height="44" rx="8"  style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }}/><text x="90" y="112" textAnchor="middle" style={{ fill: "var(--ngc-orange)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>.env file</text><text x="90" y="128" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 11 }}>PORT = 4000</text>
<rect x="200" y="66" width="150" height="44" rx="8"  style={{ fill: "var(--ngc-purple-soft)", stroke: "var(--ngc-purple)" }}/><text x="275" y="86" textAnchor="middle" style={{ fill: "var(--ngc-purple)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>real env variable</text><text x="275" y="102" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 11 }}>PORT = 8080</text>
<rect x="400" y="60" width="140" height="56" rx="10"  style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)", strokeWidth: 1.5 }}/><text x="470" y="84" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>config</text><text x="470" y="102" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 12 }}>port: 8080</text>
<path d="M160 62 L200 82 M160 114 L200 94 M350 88 L400 88"  style={{ stroke: "var(--ink)", strokeWidth: 1.5, fill: "none" }}/>
<circle r="6" cx="350" cy="88" style={{ fill: "var(--ngc-purple)" }}><animate attributeName="cx" values="350;400;400" keyTimes="0;0.5;1" dur="4s" repeatCount="indefinite" /></circle>
<text x="280" y="158" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 12 }}>Check the config once at start-up, and stop with a clear message if it is wrong.</text>
</svg>
<figcaption>Later sources win. Real environment variables beat the .env file, and both beat defaults.</figcaption>
</figure>

## Running Reliably

### Process Managers and Restart Strategies

**Process manager** = a program that starts your app, watches it, and restarts it if it crashes.

- **systemd** on Linux servers, **PM2** for Node.js apps, or the container platform itself.
- **Restart strategy** = restart after a crash, but wait a little longer each time (back-off), so a broken app does not restart thousands of times a minute.
- This is why **fail fast** works: crash cleanly, and the manager brings back a fresh copy.

```bash
npm install -g pm2
pm2 start app.js --name school-api   # start and watch
pm2 logs school-api                  # read the logs
pm2 restart school-api               # restart after a deploy
```

### One Process per Container

- In Docker and Kubernetes, run **one Node.js process per container**.
- To use more cores, run more containers. The platform's load balancer shares the traffic.
- Run `node app.js` directly, not `npm start`, so signals reach Node.js.

Kubernetes sends SIGTERM, waits about 30 seconds, then sends SIGKILL. Finish shutting down within that time.

### Running as PID 1 in Containers

- In a container, the first process gets **PID 1**. Linux treats PID 1 specially.
- PID 1 gets no default signal handling. Without your own `SIGTERM` handler, `docker stop` waits and then kills it hard.
- Fix it: handle `SIGTERM` yourself, or start with `docker run --init` (or `tini`), which forwards signals for you.

```dockerfile
FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
```

### Health Check Endpoints

**Health check** = a small URL the platform calls to ask "are you alive and ready?"

- **Liveness** = the process is running. If this fails, restart it.
- **Readiness** = the app can serve users (database connected). If this fails, send no traffic yet.
- Keep it fast and cheap. Return `200` when healthy, `503` when not.

- **Right after start,** `/health/ready` says `starting` with status 503, because the pretend database is not connected yet.
- **Send it again** a moment later. Now it says `ready` with status 200.

**Simple lesson:** a manager restarts you, a health check tells it when, and PID 1 must handle signals.

## Graceful Shutdown

**Graceful shutdown** = stop taking new work, finish the work in progress, close everything, then exit.

### Why Shutdown Must Be Graceful

- Every deploy and every scale-down stops your app.
- If it stops in the middle of a request, a user sees an error, or a payment is half saved.
- A graceful stop takes a few seconds and loses nothing.

<figure className="ngc-fig node-fig">
<svg viewBox="0 0 560 200" role="img" ariaLabelledby="fig-node-shutdown" style={{ width: "100%", maxWidth: 560, height: "auto" }}>
<title id="fig-node-shutdown">Animation: on SIGTERM the server stops new connections, waits for in-flight requests, closes the database and exits, with a timeout as a safety net</title>
<g style={{ fontSize: 12 }}>
<rect x="10" y="40" width="95" height="50" rx="8"  style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 1.5 }}/><text x="57" y="70" textAnchor="middle" style={{ fill: "var(--ngc-red)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>SIGTERM</text>
<rect x="120" y="40" width="100" height="50" rx="8"  style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)", strokeWidth: 1.5 }}/><text x="170" y="62" textAnchor="middle" style={{ fill: "var(--ngc-orange)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>server.close()</text><text x="170" y="78" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 10 }}>no new users</text>
<rect x="235" y="40" width="100" height="50" rx="8"  style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)", strokeWidth: 1.5 }}/><text x="285" y="62" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>finish work</text><text x="285" y="78" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 10 }}>in-flight requests</text>
<rect x="350" y="40" width="95" height="50" rx="8"  style={{ fill: "var(--ngc-purple-soft)", stroke: "var(--ngc-purple)", strokeWidth: 1.5 }}/><text x="397" y="62" textAnchor="middle" style={{ fill: "var(--ngc-purple)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>close</text><text x="397" y="78" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 10 }}>DB, files, workers</text>
<rect x="460" y="40" width="90" height="50" rx="8"  style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)", strokeWidth: 1.5 }}/><text x="505" y="70" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>exit(0)</text>
</g>
<circle r="7" cx="57" cy="115" style={{ fill: "var(--ngc-red)" }}><animate attributeName="cx" values="57;170;285;397;505;505" keyTimes="0;0.2;0.45;0.65;0.85;1" dur="6s" repeatCount="indefinite" /></circle>
<line x1="57" y1="150" x2="505" y2="150"  style={{ stroke: "var(--ngc-slate)", strokeDasharray: "5 5" }}/>
<text x="280" y="175" textAnchor="middle" style={{ fill: "var(--muted)", fontFamily: "var(--sans)", fontSize: 12 }}>Safety net: if this takes over 10 seconds, force exit(1)</text>
</svg>
<figcaption>Stop new work, finish old work, close resources, exit. A timer forces the exit if something hangs.</figcaption>
</figure>

### Steps

1. **Wire shutdown to signals:** `process.on("SIGTERM", shutdown)` and `SIGINT`.
2. **Stop new connections:** `server.close()`. Requests already started keep going.
3. **Close idle connections:** `server.closeIdleConnections()`, so keep-alive sockets do not hold the server open.
4. **Finish in-flight work:** wait for running requests and jobs.
5. **Close resources:** database pools, open files, message queues.
6. **Stop workers and child processes:** `worker.terminate()`, `child.kill("SIGTERM")`.
7. **Shutdown timeout:** if it all takes too long, log it and force `process.exit(1)`.

Click **▶ Run**, send `GET /report`, then press **Send SIGTERM**.

- **`server.close(callback)`** = the callback runs after the last open request is done.
- **`force.unref()`** = the safety timer does not keep the program alive by itself.
- **The `stopping` flag** = a second signal does not start a second shutdown.

**Simple lesson:** on SIGTERM, close the server, finish requests, close resources, then exit 0.

## Performance Tuning

- **`--max-http-header-size=32768`** = allow bigger request headers (the default is 16 KB). Raise it only if real clients need it.
- **Compile cache** = Node.js saves compiled code to disk, so the next start is faster. Turn it on with `module.enableCompileCache()`, or `NODE_COMPILE_CACHE=dir`.

```js
// main.mjs
import module from "node:module";

const result = module.enableCompileCache();
console.log("Compile cache on?", result.status === module.constants.compileCacheStatus.ENABLED || result.status === module.constants.compileCacheStatus.ALREADY_ENABLED);
```

Output:

```text
Compile cache on? true
```

**Simple lesson:** change tuning flags only when you have measured a real need.

## Logging and Monitoring

### Structured (JSON) Logging

**Structured log** = one JSON object per line, with fixed fields such as `time`, `level` and `msg`.

- People can still read it, and machines can search and count it.
- Log tools such as Grafana Loki, Elasticsearch and cloud consoles read JSON lines directly.
- Libraries such as **pino** write fast JSON logs.

### Log Levels

- **`error`** = something failed and needs attention.
- **`warn`** = something odd, but the app goes on.
- **`info`** = normal important events: started, stopped, user created.
- **`debug`** = details for developers. Usually off in production.
- Set the level with an environment variable, such as `LOG_LEVEL=info`.

- Never log passwords, tokens or full card numbers.

Logs are kept for months and read by many people. Treat them as public.
- Add a request id to every line, so you can follow one request (see `AsyncLocalStorage` in **Events and Utilities**).

### diagnostics_channel (Advanced)

- **`node:diagnostics_channel`** = named channels where libraries publish events, such as "HTTP request started".
- Monitoring tools subscribe to them, without changing your code.
- Node.js itself publishes on channels like `http.server.request.start`.

```js
// main.mjs
import diagnostics_channel from "node:diagnostics_channel";

const channel = diagnostics_channel.channel("school:fee-paid");
diagnostics_channel.subscribe("school:fee-paid", (msg) => console.log("monitor saw:", msg));

channel.publish({ student: "Asha", amount: 2500 });
```

Output:

```text
monitor saw: { student: 'Asha', amount: 2500 }
```

**Simple lesson:** log one JSON object per line, choose the level with an environment variable.

## Upgrading Node.js

### Safe Upgrade Steps

1. Read the release notes of the new major version, especially breaking changes.
2. Upgrade on your own computer with nvm or fnm first.
3. Run the tests, and run the app with `--trace-deprecation` to find old APIs.
4. Update the `engines` field and the Node.js version in Docker and CI.
5. Deploy to a test environment, then production.

### CI Version Matrix

- **CI matrix** = run your tests on several Node.js versions at once, such as the current LTS and the next one.
- This shows problems before you upgrade.

```yaml
# .github/workflows/test.yml (part)
strategy:
  matrix:
    node-version: [22, 24]
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node-version }}
  - run: npm ci
  - run: npm test
```

### Deprecations and Security Releases

- **`--trace-deprecation`** = show where a deprecated API is used, with a stack trace.
- **`--throw-deprecation`** = turn deprecation warnings into errors, so tests fail on them.
- **Security releases** = Node.js publishes fixes for security problems. Subscribe to the Node.js security announcements and update within days.

`old.js`

```js
const { deprecate } = require("node:util");
const oldReport = deprecate(() => "report", "oldReport() is deprecated. Use buildReport().", "DEP_SCHOOL_001");
oldReport();
```

```text
$ node --throw-deprecation old.js
DeprecationWarning: oldReport() is deprecated. Use buildReport().
```

**Simple lesson:** upgrade on your computer first, test on several versions, and never ignore security releases.

## Distribution (Advanced)

- **Single executable application (SEA)** = one file that holds Node.js and your app. Users run it without installing Node.js.
- **Bundling** (with esbuild or Rollup) = combine your code and its packages into one file. Smaller and faster to start.
- **Shipping `node_modules`** = simpler, and works for every package.
- **Native modules** = packages with C++ code (`.node` files). They cannot go inside a bundle, and they must be built for each operating system.

**Simple lesson:** ship `node_modules` by default, and bundle or build a SEA only when you need to.

## Pre-Production Checklist

The full checklist:

- **Security:** secrets in environment variables, `npm audit` clean, least permissions, HTTPS in front.
- **Reliability:** config validated at start-up, `uncaughtException` handled with fail fast, graceful shutdown, health checks.
- **Observability:** structured JSON logs with levels, diagnostic reports on fatal errors.
- **Performance:** memory limit tested under load, event loop delay measured.
- **Operations:** LTS version pinned, `engines` set, lockfile committed, `npm ci` in builds.

**Simple lesson:** run a checklist before every first launch, and automate what you can.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| `CMD ["npm", "start"]` in Docker | npm passes signals on | Run `node server.js` directly | `docker stop` kills the app hard |
| No SIGTERM handler | Node.js stops cleanly by itself | Handle SIGTERM and close the server | Requests cut off during deploys |
| Shutdown with no timeout | Closing always finishes | Add a forced exit timer | The app hangs and is killed anyway |
| Checking settings when first used | Lazy is fine | Validate all settings at start-up | Errors show up hours later |
| `console.log` text logs in production | Text is easy to read | JSON lines with levels | Logs cannot be searched or counted |
| Expecting `NODE_ENV=production` to speed up Node.js | Node.js reads it | Only libraries read it | False sense of tuning |
| Skipping security releases | Updates can wait | Upgrade within days | Known holes stay open |

</div>

## TL;DR

1. **`NODE_ENV`** = read by libraries, not by Node.js itself.
2. **Validate config** at start-up. Fail fast with exit code 1.
3. **Process manager** = restarts crashes. **One process per container.**
4. **PID 1** = handle SIGTERM, or use `--init`.
5. **Health checks** = liveness (restart me) and readiness (send me traffic).
6. **Graceful shutdown** = SIGTERM → `server.close()` → finish work → close resources → exit 0.
   - plus a forced-exit timeout
7. **Logs** = JSON lines with `level`. Choose the level with `LOG_LEVEL`.
8. **Upgrades** = test locally, CI matrix, `--trace-deprecation`, security releases.
9. **Checklist** = security, reliability, observability, performance, operations.

## Check Yourself

## Exercises

## What to Remember

- Validate settings at start-up and fail fast.
- Let a process manager or the platform restart crashes, and run one process per container.
- Expose liveness and readiness health checks.
- On SIGTERM: close the server, finish requests, close resources, exit 0, with a timeout.
- Log JSON lines with levels, and never log secrets.
- Upgrade on your computer first, test across versions, and apply security releases quickly.

Next: **Capstone: Build a Report Card Service**. You will put the whole course together in one project.
