Node.js Backend Fundamentals with Express · Data, Auth, Tests and Production
Configuration, Logging and Graceful Shutdown
Read configuration from the environment, log structured JSON, expose health checks, and shut down without dropping requests.
What you'll build
The production shell around an Express app: validated configuration from environment variables, JSON logs with pino, /healthz and /readyz endpoints, and a SIGTERM handler that stops accepting connections and finishes in-flight requests.
The idea
- Configuration comes from the environment and is validated at startup: fail fast with a clear message if something is missing.
- Structured logs (JSON) with a level, message and fields are searchable in log systems.
- Health endpoints: liveness (the process works) and readiness (dependencies are reachable).
- Graceful shutdown: on
SIGTERM(sent by Docker and Kubernetes), mark not ready, stop accepting connections withserver.close(), wait for in-flight requests, then exit.
The code
import express from "express";
import pino from "pino";
import { z } from "zod";
const config = z.object({
PORT: z.coerce.number().int().default(0),
LOG_LEVEL: z.enum(["debug", "info", "warn"]).default("info"),
DATABASE_URL: z.url(),
}).parse({ ...process.env, DATABASE_URL: process.env.DATABASE_URL ?? "postgres://db.internal/shop" });
const lines = [];
const logger = pino({ level: config.LOG_LEVEL, base: undefined, timestamp: false }, { write: (l) => lines.push(l.trim()) });
let ready = true;
const app = express();
app.get("/healthz", (req, res) => res.json({ ok: true }));
app.get("/readyz", (req, res) => res.status(ready ? 200 : 503).json({ ready }));
app.get("/slow", async (req, res) => { await new Promise((r) => setTimeout(r, 300)); res.json({ done: true }); });
const server = app.listen(config.PORT, () => logger.info({ port: "free port" }, "listening"));
process.on("SIGTERM", () => {
ready = false;
logger.info("SIGTERM: draining");
server.close(() => { logger.info("all connections closed, exiting"); console.log(lines.join("\n")); });
});
const base = `http://127.0.0.1:${server.address().port}`;
const inFlight = fetch(`${base}/slow`).then(async (r) => logger.info({ status: r.status }, "in-flight request finished"));
await new Promise((r) => setTimeout(r, 50));
process.kill(process.pid, "SIGTERM");
await inFlight;
Output
{"level":30,"port":"free port","msg":"listening"}
{"level":30,"msg":"SIGTERM: draining"}
{"level":30,"status":200,"msg":"in-flight request finished"}
{"level":30,"msg":"all connections closed, exiting"}Run this on your computer — see “Get Started” for setup.
Common mistakes
- Reading
process.envall over the code, with no validation until something fails at 3 a.m. console.logstrings that can't be filtered or aggregated.process.exit(0)on SIGTERM, dropping in-flight requests.- A readiness check that depends on the process only, so traffic arrives before the database is reachable.
Key points
- Validate configuration at startup.
- Log structured JSON with levels.
- Separate liveness and readiness, and drain on SIGTERM before exiting.
Further reading: Express production best practices · pino · Node.js process signals
Practice questions
What should an Express app do when it receives SIGTERM?
Answer
Mark itself not ready, stop accepting new connections (server.close), let in-flight requests finish, then exit before the platform's grace period ends.
Why validate configuration at startup?
Answer
Missing or malformed settings fail immediately with a clear message, instead of causing confusing errors later when the code path is first used.
What's the difference between liveness and readiness endpoints?
Answer
Liveness says the process works (restart if not); readiness says it can serve traffic now, including dependencies (remove from load balancing if not).
Open in the app to mark lessons complete, take notes and track your progress.