NodeJSPreparing for Production

Preparing for Production

Before you think about where to host, the app itself has to be production-ready. The same code that's fine in development will leak stack traces, crash on the first uncaught error, ignore the other seven CPU cores, and lose in-flight requests on every deploy — unless you prepare it. This page is the production-readiness checklist that lives in your code: setting NODE_ENV, handling errors so the process survives, graceful shutdown, the security and performance baseline, health checks, and logging — the things that must be true before deployment, regardless of the hosting model you choose.

Set NODE_ENV and read config from the environment

JS
// 1. NODE_ENV=production must be set in the environment (covered earlier):
//    → faster framework code paths, no leaked stack traces.

// 2. Config comes from validated env vars — never hard-coded, fail fast if missing:
import { config } from './config'   // the validated, frozen config module

const server = app.listen(config.PORT, () =>
  console.log(`listening on ${config.PORT} in ${config.NODE_ENV} mode`),
)
Production starts with `NODE_ENV=production` and config injected from validated environment variables
Two foundations from earlier in this section must be in place. First, **[`NODE_ENV=production`](/nodejs/node-env)** in the runtime environment — it switches frameworks into their fast paths and stops them leaking stack traces to clients; an unset `NODE_ENV` silently runs the slow, leaky development mode. Second, all **[configuration comes from the environment](/nodejs/config-best-practices)**, validated once at startup so a missing `DATABASE_URL` or `JWT_SECRET` crashes the process *immediately* with a clear message rather than failing mysteriously under load. Get these right and the app boots only when it's correctly configured, in the correct mode — the precondition for everything else here.
Handle errors so the process survives

JS
// An UNHANDLED error will crash Node. In production you must catch the last-resort cases:
process.on('uncaughtException', (err) => {
  logger.fatal(err, 'uncaught exception')   // log it...
  process.exit(1)                           // ...then EXIT — state may be corrupt; let the supervisor restart
})

process.on('unhandledRejection', (reason) => {
  logger.error(reason, 'unhandled promise rejection')
  process.exit(1)                           // treat like uncaughtException
})

// But these are SAFETY NETS — real handling belongs in try/catch and Express error middleware.
After an `uncaughtException`, log and EXIT — don't try to keep running; the process state may be corrupt
A single unhandled exception or rejected promise will, by default, **crash** a Node process — and an unhandled async error can take the whole server down. You need last-resort handlers for `uncaughtException` and `unhandledRejection`, but the crucial rule is what they should do: **log the error and then exit** (`process.exit(1)`), letting your [process manager](/nodejs/pm2) or orchestrator start a fresh instance. Do **not** try to swallow the error and keep serving — once an exception escaped all your handling, the application is in an *unknown, possibly corrupt* state (half-finished transactions, leaked resources), and continuing risks serving wrong data or compounding the failure. The handlers are a **safety net** for logging and clean exit, not a substitute for real error handling: wrap risky operations in `try/catch`, handle promise rejections, and use [Express error middleware](/nodejs/error-handling) for request errors. "Let it crash, then restart clean" is the resilient pattern.
Graceful shutdown

JS
// On deploy/scale-down, the platform sends SIGTERM. Don't drop in-flight requests:
function shutdown(signal) {
  logger.info(`${signal} received — shutting down gracefully`)
  server.close(async () => {            // 1. stop accepting NEW connections, finish in-flight ones
    await db.end()                      // 2. close DB pool, drain queues, release resources
    await redis.quit()
    process.exit(0)                     // 3. exit cleanly
  })
  // 4. Hard cap so a stuck connection can't block shutdown forever:
  setTimeout(() => process.exit(1), 10_000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
Handle `SIGTERM` to finish in-flight requests and close resources before exiting — otherwise every deploy drops live requests
Every deploy, scale-down, or container restart sends your process a **`SIGTERM`**, and the default behavior is to die *instantly* — abandoning in-flight requests (users get errors), leaving database connections and queues half-open, and potentially corrupting work mid-flight. **Graceful shutdown** fixes this: on `SIGTERM`/`SIGINT`, **stop accepting new connections** (`server.close()`), let **in-flight requests finish**, then **close resources** (database pool, [Redis](/nodejs/redis), message queue consumers) and exit `0`. Always add a **timeout** (`setTimeout(...).unref()`) so a single stuck connection can't hang the shutdown forever — after the grace period, force-exit. This is what makes deploys **zero-downtime** in concert with a load balancer that stops routing to a draining instance, and it's mandatory in container/Kubernetes environments where `SIGTERM`-then-kill is the normal lifecycle. (See [health checks](/nodejs/health-checks) for failing readiness during drain.)
Security and performance baseline
  • Run TLS — serve over HTTPS (terminated at a proxy/LB), and set security headers with Helmet.

  • Don't leak errors — generic messages and status codes to clients; full detail only in logs.

  • Rate-limit and validate — cap request rates and validate every input; never trust the client.

  • Drop privileges — never run as root; run as an unprivileged user, especially in containers.

  • Use all the cores — run one instance per CPU via cluster/PM2 or multiple replicas; a single Node process uses one core.

  • Enable compression and sensible caching, and keep dependencies patched (npm audit).

  • Set timeouts — server, and on every outbound call, so a slow dependency can't pile up requests.

A production app runs over TLS, hides error detail, validates and rate-limits input, drops root, and uses every CPU core
Production hardening is a baseline, not an afterthought. On **security**: serve over TLS, set headers with [Helmet](/nodejs/helmet), return **generic errors** to clients (detail goes to logs only), [validate](/nodejs/zod) and **rate-limit** all input, and **never run as root** — drop to an unprivileged user, which matters especially in [containers](/nodejs/docker). On **performance**: a single Node process uses **one CPU core**, so run one instance **per core** via [cluster](/nodejs/cluster-module)/[PM2](/nodejs/pm2) or multiple container replicas behind a load balancer to use the whole machine; enable [compression](/nodejs/compression) and caching; and put **timeouts** on the server and every [outbound dependency](/nodejs/consuming-apis) so one slow service can't exhaust your connections. None of this is exotic — it's the standard checklist that separates a demo from a deployable service.
Observability — you can't fix what you can't see

JS
// Health endpoints so load balancers / orchestrators know if the instance is usable:
app.get('/health/live', (req, res) => res.sendStatus(200))            // is the process up?
app.get('/health/ready', async (req, res) => {                        // can it serve traffic?
  const ok = await db.ping().then(() => true).catch(() => false)
  res.sendStatus(ok ? 200 : 503)
})

// Structured logging to stdout (the platform collects it) — no console.log in prod:
logger.info({ event: 'startup', port: config.PORT })
Ship health-check endpoints and structured logging before you deploy — production debugging depends on them
Once an app is on a server you can't watch its terminal, so **observability must be built in before deploy**. Expose **[health-check endpoints](/nodejs/health-checks)** — a *liveness* check (is the process up?) and a *readiness* check (can it actually serve, e.g. is the database reachable?) — so load balancers and orchestrators route traffic only to healthy instances and restart dead ones. Emit **[structured logs](/nodejs/structured-logging)** (JSON, with levels and correlation IDs) to **stdout**, where your platform collects and aggregates them — `console.log` debugging doesn't scale in production. Layer on **metrics and error tracking** ([APM](/nodejs/monitoring-apm)) so you get alerted on regressions rather than learning from users. These aren't nice-to-haves: when something breaks at 3am, health checks, logs, and metrics are the *only* way you'll know what happened and where.
Next
Keep your app alive and using every core with a process manager: [Process Management with PM2](/nodejs/pm2).