Production Server
Putting it all together
Section titled “Putting it all together”Every other lesson in this module covered one guard at a time: an error listener here, a body limit there, a graceful shutdown handler somewhere else. A real production server needs all of them at once, in the same file, cooperating instead of getting in each other’s way. This lesson is one server that does exactly that — every guard from this module, wired into a single request handler, with a request that exercises each one.
The server
Section titled “The server”Output (verified on Node 24)
{"method":"GET","path":"/health","status":200,"clientIp":"::1"}
{"method":"POST","path":"/items","status":413}
{"method":"POST","path":"/items","status":400,"clientIp":"::1"}
{"method":"POST","path":"/items","status":201,"clientIp":"::1"}
{"level":"info","msg":"SIGTERM received, closing server"}
{"level":"info","msg":"server closed cleanly"}Four requests, four different guards, one server: a plain health check, a body over MAX_BODY (413, via the body-limit lesson’s req.destroy() pattern), a malformed JSON body (400, via a try/catch around JSON.parse), and a valid request that actually creates an item (201). Then SIGTERM triggers server.close(), which logs once it has finished — since nothing was still in flight, it closes immediately, but the same handler would wait for an in-flight response first if one existed, exactly like the graceful-shutdown lesson.
Walking through the guards
Section titled “Walking through the guards”| Guard | From this module | What it stops |
|---|---|---|
MAX_BODY + req.destroy() | JSON API lesson | An unbounded upload growing memory forever |
try/catch around JSON.parse | JSON API lesson | A SyntaxError turning into an unhandled rejection |
res.on('error', ...) | HTTP Server lesson | A stream error (like a double res.end()) crashing the whole process |
if (res.writableEnded) return; in finish() | HTTP Server lesson | Ever calling res.end() a second time on the same response |
res.setHeader('X-Content-Type-Options', 'nosniff') | This lesson | Browsers MIME-sniffing a response into a different, possibly dangerous content type |
Reading req.socket.remoteAddress, not blindly trusting X-Forwarded-For | This lesson | Spoofed client-IP headers when there is no reverse proxy actually setting them |
process.once('SIGTERM', shutdown) + server.close() | HTTP Server lesson | Dropping in-flight requests when a process manager asks the server to stop |
One log(...) call per branch, structured JSON | This lesson | A wall of unstructured console.log calls that nothing downstream can parse |
Security headers and trusting proxy headers
Section titled “Security headers and trusting proxy headers”X-Content-Type-Options: nosniff tells browsers to trust your declared Content-Type instead of guessing from the body — without it, a browser that misidentifies a JSON or text response as HTML can expose an XSS vector that a correct Content-Type alone would have prevented. For a server that only ever runs behind TLS, Strict-Transport-Security (HSTS) is worth adding too, telling browsers to never downgrade to plain HTTP for your domain again — but only set it once you are certain every path is actually served over HTTPS, since it is a promise the browser holds you to.
X-Forwarded-For (and X-Forwarded-Proto, X-Forwarded-Host) are headers a reverse proxy sets to tell your app what the original client’s address and protocol were, since the proxy’s own IP is what your server actually sees on the socket. The catch: any client can set these headers themselves in a direct request. Trusting them blindly (for rate limiting, access control, or logging) means a client can claim to be any IP address it wants. Only trust them when you know a proxy you control is the only thing that can reach your process directly — and even then, prefer trusting just the immediate hop, not the full header value a client could have prepended to.
Mental Model
Section titled “Mental Model”A production server is not one big feature — it is the same request/response contract from the HTTP Server lesson, wrapped in a series of independent guards that each answer one question: is the input too big? Is it valid JSON? Did something throw? Are we shutting down? None of the guards depend on each other, which is exactly what lets them compose into one handler without any single one growing more complex than the others.
Trade-offs
Section titled “Trade-offs”Every guard in this lesson is a small amount of code that does nothing in the common case and only matters on the day something goes wrong — which makes it easy to skip under time pressure and easy to forget it is missing until an incident makes it obvious. The finish() helper and structured log() calls here also encode a specific set of decisions (JSON logs, a single response helper) that a real codebase might make differently — the point is not this exact shape, but that a production server needs some consistent, centralized answer to “how do I finish a response” and “how do I log a request,” rather than each route reinventing both.
Common Misconceptions
Section titled “Common Misconceptions”- “Production-readiness is a separate phase you add at the end.” Every guard here is small and independent — the cheapest time to add a body limit or an error listener is when you write the route, not in a retrofit once something has already failed in production.
- “Structured logging just means using a library like pino.” The structure (one JSON object per event, consistent fields) matters more than the library — the demo above gets the same query-ability from
console.log(JSON.stringify(...))that a logging library would give you, just without the extra features (levels, transports, sampling) a real one adds. - “X-Forwarded-For tells you the real client IP.” Only if you control the proxy setting it and nothing else can reach your server directly. Otherwise it is exactly as trustworthy as any other header a client can set by hand.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated “production-ready” servers often add real touches — a body limit here, a try/catch there — without connecting them: a body-too-large branch that writes a response but forgets to guard against finish() running again afterward is a subtle, easy way to reintroduce the double-res.end() crash this whole module has been building defenses against.
🐛 Spot the bug
function finish(req, res, status, body) {const json = JSON.stringify(body);res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': json.length, // BUG: character length, not byte length});res.end(json);}
// Called with a name containing multi-byte charactersfinish(req, res, 201, { id: 42, name: 'José 🎉' });Show the answer
json.length counts UTF-16 code units, not bytes — for a body containing accented letters or emoji (or Thai text, as in the earlier byte-length example in this module), the real UTF-8 byte count (what Content-Length must declare) is larger than .length reports. The client reads exactly Content-Length bytes and considers the response complete at that point, so it silently truncates the body partway through a multi-byte character, and depending on the HTTP client, that broken byte boundary can also desync the next response on a reused keep-alive connection. The fix, from the HTTP Server lesson: Buffer.byteLength(json) instead of json.length — or better, let res.end(json) alone compute Content-Length by not setting it by hand at all.