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.
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:
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;import { config } from "./config.js";
console.log(`env=${config.NODE_ENV} port=${config.PORT} log=${config.LOG_LEVEL} db=${config.DATABASE_URL}`);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.jsInvalid 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.dbThe 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:
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 }),
};
}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" });node use-logger.js{"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:
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:
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+Cserver.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):
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);node shutdown-demo.jsListening on 3000
SIGINT received: finishing in-flight requests
response: {"report":"done"}
All connections closed, exitingThe 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
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);node cors-config.js[]
[ 'https://a.example.com', 'https://b.example.com' ]Key takeaway#
Next: Deploying Node.js Apps.
Finished this lesson?