Production Readiness Checklist
Shipping a Node.js app to production is more than running node server.js on a bigger machine. Production demands predictable behavior under load, observability when things go wrong, and clean recovery from crashes and deploys. This page walks through a concrete readiness checklist you can apply before promoting any service, covering environment configuration, process supervision, logging, health, shutdown, and dependency hygiene.
Set NODE_ENV to production
The single most impactful flag is NODE_ENV=production. Many libraries — Express, React server rendering, template engines — branch on this value to disable verbose debugging, enable view caching, and skip development-only validation. Setting it incorrectly can cut throughput by a large margin.
# Set it explicitly in your runtime, not just in code
export NODE_ENV=production
node --enable-source-maps server.js
Read it once at startup and fail fast if required configuration is missing, rather than discovering it mid-request.
// config.js
const required = ["DATABASE_URL", "SESSION_SECRET"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(", ")}`);
process.exit(1);
}
export const config = {
env: process.env.NODE_ENV ?? "development",
port: Number(process.env.PORT ?? 3000),
databaseUrl: process.env.DATABASE_URL,
};
Never hardcode secrets in source or commit
.envfiles. Inject configuration through your orchestrator (Kubernetes Secrets, AWS SSM, Docker secrets) and read it fromprocess.env.
Run under a process manager
A bare node process that crashes stays dead. In production you want automatic restarts, multi-core utilization via the cluster, and centralized logs. On a VM, PM2 is the common choice; in containers, the orchestrator (Kubernetes, ECS, systemd) plays the supervisor role and you should run a single Node process per container.
# PM2 on a traditional host
pm2 start server.js --name api -i max # one worker per CPU core
pm2 save
pm2 startup # restart on reboot
| Environment | Supervisor | Restart policy |
|---|---|---|
| Bare VM | PM2 / systemd | Process manager restarts |
| Docker | Docker / Compose | restart: unless-stopped |
| Kubernetes | kubelet | Pod restartPolicy + probes |
In containers, do not also run PM2’s cluster mode. Let the orchestrator scale replicas and keep one process per container so health probes map cleanly to a single workload.
Use structured logging
console.log is fine for a script, but production needs structured, leveled JSON that log aggregators (Loki, CloudWatch, Datadog) can parse and index. Use a fast logger like pino, attach request context, and write to stdout so the platform captures it.
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// Avoid pretty-printing in prod; emit raw JSON for ingestion
redact: ["req.headers.authorization", "*.password"],
});
logger.info({ userId: 42, route: "/orders" }, "order created");
Output:
{"level":30,"time":1718323200000,"userId":42,"route":"/orders","msg":"order created"}
Add health checks
Orchestrators need a cheap endpoint to decide whether to route traffic (readiness) and whether to restart the process (liveness). Keep liveness trivial and put dependency checks in readiness so a slow database doesn’t trigger a restart loop.
import express from "express";
import { logger } from "./logger.js";
const app = express();
// Liveness: is the event loop responsive?
app.get("/healthz", (_req, res) => res.status(200).send("ok"));
// Readiness: can we serve real traffic?
app.get("/readyz", async (_req, res) => {
try {
await db.query("SELECT 1");
res.status(200).json({ status: "ready" });
} catch (err) {
logger.warn({ err }, "readiness check failed");
res.status(503).json({ status: "unavailable" });
}
});
Shut down gracefully
When a deploy or scale-down sends SIGTERM, abruptly exiting drops in-flight requests and corrupts connections. Stop accepting new work, drain active requests, close pools, then exit — with a timeout as a backstop.
const server = app.listen(config.port, () =>
logger.info(`listening on ${config.port}`),
);
function shutdown(signal) {
logger.info(`${signal} received, draining...`);
server.close(async () => {
await db.end();
logger.info("clean shutdown complete");
process.exit(0);
});
// Force-exit if drain hangs
setTimeout(() => {
logger.error("forced shutdown after timeout");
process.exit(1);
}, 10_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Capture errors centrally
Uncaught exceptions and unhandled promise rejections will eventually crash the process; you want them reported before they do. Wire an error monitor (Sentry, OpenTelemetry) and treat a truly uncaught exception as fatal — log it, flush, and let the supervisor restart a clean process.
process.on("unhandledRejection", (reason) => {
logger.error({ reason }, "unhandled rejection");
});
process.on("uncaughtException", (err) => {
logger.fatal({ err }, "uncaught exception, exiting");
process.exit(1); // restart with a known-good state
});
Strip development dependencies
Dev tooling (test runners, linters, bundlers) inflates image size and attack surface. Install only production dependencies in the final artifact and ensure your lockfile is committed for reproducible builds.
# Reproducible, production-only install
npm ci --omit=dev
# Audit before promoting
npm audit --omit=dev --audit-level=high
Best Practices
- Set
NODE_ENV=productionin the runtime environment and validate required config at startup, failing fast on anything missing. - Run exactly one Node process per container and let the orchestrator handle restarts, scaling, and health probes.
- Emit structured JSON logs to stdout with secrets redacted; never log tokens, passwords, or full request bodies.
- Separate liveness from readiness so dependency hiccups don’t trigger needless restarts.
- Handle
SIGTERM/SIGINTto drain in-flight work, and back the drain with a hard timeout. - Report unhandled rejections and treat uncaught exceptions as fatal, restarting from a clean state.
- Build artifacts with
npm ci --omit=devand runnpm auditin CI to keep dependencies lean and patched.