node server.js in a terminal isn't a deploymentRunning node server.js directly and leaving the terminal open technically starts your server — but it has none of the properties an actual deployment needs. It dies the instant the SSH session disconnects, or the terminal closes, or the process throws an uncaught exception. There's no automatic restart, no built-in log rotation or aggregation, and (on a multi-core machine) no way to use more than the single CPU core that one Node process runs on. A process manager — pm2, systemd, or a container orchestrator's own restart policy (Kubernetes, Docker Swarm) — exists specifically to close those gaps: automatically restarting a crashed process, capturing and rotating logs, optionally running multiple instances across CPU cores, and starting the app back up automatically if the underlying machine reboots.
"Works on my machine" bugs are usually a parity gap between development and production: a different Node version, a dependency that resolved to a different version because package-lock.json wasn't respected, environment variables set in one place and not the other. Pinning the Node version (an "engines" field in package.json, or a tool like nvm/.nvmrc), committing the lockfile, and using npm ci (which installs exactly what the lockfile specifies, and fails outright rather than silently resolving something different) in CI and production deploys all close that gap. The goal isn't that dev and prod are identical in every respect — different databases and scale are expected — just that the application code and its dependencies behave the same way in both places.
SIGTERMWhen a process manager or orchestrator needs to stop your app — deploying a new version, scaling down, restarting after a crash — it typically sends SIGTERM first, as a polite "please wind down" signal, and only escalates to the immediate, un-catchable SIGKILL if the process hasn't exited within some grace period. An app that doesn't handle SIGTERM gets killed exactly the same way an app that ignores it does — mid-request, with in-flight work simply dropped and any pending database writes potentially left in an inconsistent state.
const server = app.listen(3000);
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down gracefully");
server.close(() => {
// stops accepting NEW connections, but lets in-flight requests finish first
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 forever
setTimeout(() => process.exit(1), 10_000).unref();
});
server.close() stops the server from accepting brand-new connections but deliberately lets requests already in progress finish normally — that's the entire point of handling the signal at all, rather than just letting the process die immediately. Closing the database pool afterward, rather than during, avoids yanking a connection out from under a query that's still running as part of one of those in-flight requests. This is a small amount of code that's very easy to skip — and it's exactly the kind of detail that only shows up as a problem under real deploy traffic, when it looks like dropped requests or corrupted writes with no obvious cause.