Skip to content
Laviyx Learn

Try

Node.js and Express course menu

Node.js Configuration, Logging and Graceful Shutdown

Prepare a Node.js app for production: validated configuration from environment variables, structured JSON logging with levels, request logging, health checks, and graceful shutdown on SIGTERM so in-flight requests finish before the process exits.

Advanced9 min readUpdated
On this page

Code that works on your laptop also needs to be configurable (different settings per environment), observable (logs that explain what happened) and well-behaved when the platform stops or restarts it. These habits separate a demo from a production service.

Validated configuration#

Read every setting once, in one module, and fail fast at start-up if something required is missing or invalid, instead of crashing later in the middle of a request:

config.js (JavaScript)
import { z } from "zod";

const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  DATABASE_URL: z.string().min(1),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const parsed = Env.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid configuration:");
  for (const issue of parsed.error.issues) console.error(`  ${issue.path.join(".")}: ${issue.message}`);
  process.exit(1);
}

export const config = parsed.data;
show-config.js (JavaScript)
import { config } from "./config.js";

console.log(`env=${config.NODE_ENV} port=${config.PORT} log=${config.LOG_LEVEL} db=${config.DATABASE_URL}`);
Terminal
node show-config.js; echo "exit code $?"
DATABASE_URL=file:shop.db JWT_SECRET=short node show-config.js; echo "exit code $?"
DATABASE_URL=file:shop.db JWT_SECRET=a-long-random-secret-from-a-password-manager PORT=8080 node show-config.js
Output
Invalid configuration:
  DATABASE_URL: Invalid input: expected string, received undefined
  JWT_SECRET: Invalid input: expected string, received undefined
exit code 1
Invalid configuration:
  JWT_SECRET: JWT_SECRET must be at least 32 characters
exit code 1
env=development port=8080 log=info db=file:shop.db

The app refuses to start with a clear list of problems, and with valid settings config holds typed values (the port is a number). The rest of the code imports config and never touches process.env directly.

Structured logging#

console.log("user logged in") is fine while developing, but production logs are searched and filtered by tools, so they should be structured: one JSON object per line, with a level, a timestamp and fields:

logger.js (JavaScript)
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };

export function createLogger(minLevel = "info", base = {}) {
  const log = (level, msg, fields = {}) => {
    if (LEVELS[level] < LEVELS[minLevel]) return;
    const line = { level, time: fields.time ?? new Date().toISOString(), msg, ...base, ...fields };
    (level === "error" ? process.stderr : process.stdout).write(JSON.stringify(line) + "\n");
  };
  return {
    debug: (msg, f) => log("debug", msg, f),
    info: (msg, f) => log("info", msg, f),
    warn: (msg, f) => log("warn", msg, f),
    error: (msg, f) => log("error", msg, f),
    child: (extra) => createLogger(minLevel, { ...base, ...extra }),
  };
}
use-logger.js (JavaScript)
import { createLogger } from "./logger.js";

const time = "2026-10-25T09:30:00.000Z";            // fixed so the example's output is repeatable
const log = createLogger("info", { service: "orders-api" });

log.debug("cache miss", { time, key: "product:7" });            // below "info": not printed
log.info("order placed", { time, orderId: 1042, total: 1299 });
log.child({ requestId: "req-8f2a" }).warn("payment retry", { time, attempt: 2 });
log.error("payment failed", { time, orderId: 1043, reason: "card declined" });
Terminal
node use-logger.js
Output
{"level":"info","time":"2026-10-25T09:30:00.000Z","msg":"order placed","service":"orders-api","orderId":1042,"total":1299}
{"level":"warn","time":"2026-10-25T09:30:00.000Z","msg":"payment retry","service":"orders-api","requestId":"req-8f2a","attempt":2}
{"level":"error","time":"2026-10-25T09:30:00.000Z","msg":"payment failed","service":"orders-api","orderId":1043,"reason":"card declined"}

Log levels let production show info and above while development shows debug too. A child logger adds fields (such as a request id) to every line, so all logs from one request can be found together. In real projects use a library such as pino (fast JSON logging, used by Fastify) or winston, which work the same way.

Never log passwords, tokens, full card numbers or other secrets.

Health checks#

Hosting platforms and load balancers call a health endpoint to decide whether to send traffic to an instance:

JavaScript
app.get("/healthz", (req, res) => res.json({ status: "ok" }));

A "readiness" check can also verify the database connection, so traffic only arrives once the app can actually serve it.

Graceful shutdown#

When a platform deploys a new version or scales down, it sends the process a SIGTERM signal, waits a few seconds, then kills it. A graceful shutdown stops accepting new connections, lets in-flight requests finish, closes the database, then exits:

graceful.js (JavaScript)
import express from "express";

const app = express();
app.get("/api/report", async (req, res) => {
  await new Promise((resolve) => setTimeout(resolve, 1000));   // a slow request
  res.json({ report: "done" });
});

const server = app.listen(3000, () => console.log("Listening on 3000"));

function shutdown(signal) {
  console.log(`${signal} received: finishing in-flight requests`);
  server.close(() => {
    console.log("All connections closed, exiting");       // close the database here too
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10_000).unref();     // force exit if it takes too long
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));          // Ctrl+C

server.close() stops accepting new connections but waits for open ones; the timer guarantees the process still exits if something hangs.

To see it work, this script imports the server, starts a slow request, and triggers the shutdown handler 200 ms later, while the request is still running (process.emit calls the handler directly, as a real Ctrl+C would):

shutdown-demo.js (JavaScript)
import "./graceful.js";

setTimeout(async () => {
  const pending = fetch("http://localhost:3000/api/report").then((res) => res.json());
  setTimeout(() => process.emit("SIGINT"), 200);
  console.log("response:", JSON.stringify(await pending));
}, 300);
Terminal
node shutdown-demo.js
Output
Listening on 3000
SIGINT received: finishing in-flight requests
response: {"report":"done"}
All connections closed, exiting

The shutdown began while the request was in flight, the request still completed, and only then did the server close and exit.

Common mistakes#

Practice#

Practice exercise

Extend the configuration schema with CORS_ORIGINS, a comma-separated list of URLs (for example https://a.example.com,https://b.example.com) that should become an array, defaulting to an empty array.

Show hint

z.string().default("").transform((s) => s.split(",").map((x) => x.trim()).filter(Boolean)).

Show solution
cors-config.js (JavaScript)
import { z } from "zod";

const Env = z.object({
  CORS_ORIGINS: z.string().default("").transform((s) => s.split(",").map((x) => x.trim()).filter(Boolean)),
});

console.log(Env.parse({}).CORS_ORIGINS);
console.log(Env.parse({ CORS_ORIGINS: "https://a.example.com, https://b.example.com" }).CORS_ORIGINS);
Terminal
node cors-config.js
Output
[]
[ 'https://a.example.com', 'https://b.example.com' ]

Key takeaway#

Next: Deploying Node.js Apps.

Finished this lesson?