Skip to content

Production Server

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.

Node.js
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.

GuardFrom this moduleWhat it stops
MAX_BODY + req.destroy()JSON API lessonAn unbounded upload growing memory forever
try/catch around JSON.parseJSON API lessonA SyntaxError turning into an unhandled rejection
res.on('error', ...)HTTP Server lessonA stream error (like a double res.end()) crashing the whole process
if (res.writableEnded) return; in finish()HTTP Server lessonEver calling res.end() a second time on the same response
res.setHeader('X-Content-Type-Options', 'nosniff')This lessonBrowsers MIME-sniffing a response into a different, possibly dangerous content type
Reading req.socket.remoteAddress, not blindly trusting X-Forwarded-ForThis lessonSpoofed client-IP headers when there is no reverse proxy actually setting them
process.once('SIGTERM', shutdown) + server.close()HTTP Server lessonDropping in-flight requests when a process manager asks the server to stop
One log(...) call per branch, structured JSONThis lessonA 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.

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.

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.

  • “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.

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 characters
finish(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.

In the combined server, why does the POST /items handler check err.handled before calling finish(500, ...)?
What does if (res.writableEnded) return; inside finish() protect against?
Given the four requests the demo fires (GET /health, an oversized POST, malformed JSON, and a valid POST), what is the 2nd logged status code?
Why does server.close() finish immediately in this demo instead of waiting?
process.emit('SIGTERM');
server.closeAllConnections();
Why is X-Forwarded-For unsafe to trust for access control unless you control the proxy setting it?
A handler manually sets Content-Length: json.length for a response body containing Thai text. What goes wrong?
const json = JSON.stringify({ name: 'สมชาย ใจดี' });
res.writeHead(200, { 'Content-Length': json.length });