Skip to content
Node.js nd deployment 4 min read

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 .env files. Inject configuration through your orchestrator (Kubernetes Secrets, AWS SSM, Docker secrets) and read it from process.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
EnvironmentSupervisorRestart policy
Bare VMPM2 / systemdProcess manager restarts
DockerDocker / Composerestart: unless-stopped
KuberneteskubeletPod 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=production in 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/SIGINT to 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=dev and run npm audit in CI to keep dependencies lean and patched.
Last updated June 14, 2026
Was this helpful?