Skip to main content
CodeOath
← All posts

Node.js95 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 13 of 14 · ~4 min

Deployment Basics

Why node server.js in a terminal isn't a deployment

Typing node server.js into a terminal and walking away looks like a deployment, right up until something ordinary happens. Close that SSH session, or let one uncaught exception through, and billhook is gone — nothing brings it back, nothing rotates whatever it was logging, and on a machine with more than one CPU core, that single process was only ever going to use exactly one of them no matter how many sit idle. Everything a real deployment needs instead — restart-on-crash, log capture that survives the process dying, running several instances spread across cores, coming back up on its own after the host machine reboots — is what a process manager (pm2, systemd, or whatever restart policy a container orchestrator provides) is actually for.

Environment parity

The .env-ordering incident from a few chapters back is a small instance of a much larger category: a "works on my machine" bug is almost always some environment failing to match another one exactly — a Node version that drifted, a package that resolved to something slightly different because the lockfile got ignored somewhere, a variable that's set in one place and quietly absent in another. Three habits close most of that gap: pin the Node version explicitly (the "engines" field, or a .nvmrc file), always commit the lockfile, and run npm ci — never npm install — in CI and in production, since ci refuses to improvise and simply fails if the lockfile and what's on disk disagree. None of this demands staging and production be identical everywhere; different scale and different databases are fine. What has to match is narrower and non-negotiable: billhook's own code and the exact dependency versions it runs against.

Graceful shutdown, SIGTERM, and the payment that must not get dropped

This is the highest-stakes chapter in the whole reference, because getting it wrong doesn't just mean a dropped connection — for billhook, it can mean a payment that never makes it into Northbound's records. Whatever ends the process — a fresh deploy, an autoscaler pulling an instance down, a restart after a crash — reaches for SIGTERM first: a courteous "start wrapping up," with the hard, un-ignorable SIGKILL held in reserve for whenever the grace period runs out. Not listening for SIGTERM and listening but doing nothing about it land in exactly the same place — whatever request was mid-flight gets cut off regardless, work simply abandoned wherever it happened to be. For billhook specifically, mid-request during a deploy can mean Meridian already sent the payment-captured webhook, billhook's process died before writing the order to Postgres, and — because billhook never got the chance to return a 2xx — Meridian's retry logic is the only thing standing between that payment and it silently never showing up on Northbound's own dashboard.

const server = app.listen(3000);

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown); // Ctrl+C locally sends this, not SIGTERM — handle both or dev never sees it exercised

function shutdown(signal) {
  console.log(`${signal} received, shutting down gracefully`);

  server.close(() => {
    // stops accepting NEW connections, but lets in-flight webhook
    // deliveries finish — including the database write that follows them
    console.log("HTTP server closed");
    pool.end(() => {
      console.log("Database pool closed");
      process.exit(0);
    });
  });

  // Safety net: force-exit if shutdown takes too long, rather than hanging
  setTimeout(() => process.exit(1), 10_000).unref();
}

What server.close() actually buys: the door closes to anything new the instant it's called, but nothing that's already inside gets thrown out — every in-flight request is allowed to run to completion. That's the whole reason any of this signal-handling exists in the first place, instead of just letting SIGTERM end the process on the spot. The pool gets closed only once the HTTP server has fully wound down, deliberately not in parallel with it — closing it any earlier risks pulling a connection out from underneath an order write that one of those still-finishing webhook deliveries is depending on. And handling SIGINT alongside SIGTERM isn't decoration: a process manager in production sends SIGTERM, but a developer hitting Ctrl+C locally sends SIGINT — code that only listens for SIGTERM looks perfectly fine through every local test and only reveals that it never actually runs the shutdown path once it meets a real deploy. It's a handful of extra lines, easy to leave out under deadline pressure — and its absence is exactly the kind of thing that never shows up in a demo, only in production, disguised as a payment that mysteriously never arrived.