What running in production means
- It restarts when it crashes, and starts again when the machine reboots.
- It is configured by the environment, not by a file in the image.
- It says whether it is alive and whether it is ready, and something checks.
- It finishes the requests it is serving before it exits.
- Its logs go to stdout, where the platform collects them.
Something must restart it
node app.js in a terminal is not a deployment: it dies with your SSH session, and it never comes back after a crash.
# a platform: Render, Railway, Fly, App Runner — it handles this for you
# a container: Docker with restart: unless-stopped, or Kubernetes
# a plain VM: systemd
# a quick answer: pm2 start app.js -i max
# systemd unit, in full
[Service]
ExecStart=/usr/bin/node /srv/doors/app.js
Restart=always
Environment=NODE_ENV=production
EnvironmentFile=/etc/doors.env
Health and readiness are different questions
// alive: is the process functioning? cheap, no dependencies
app.get("/healthz", (req, res) => res.status(200).json({ ok: true }));
// ready: should traffic be sent here? checks dependencies, and shutdown state
app.get("/readyz", async (req, res) => {
if (shuttingDown) return res.status(503).json({ ready: false });
try {
await db.query("SELECT 1");
res.json({ ready: true });
} catch {
res.status(503).json({ ready: false, reason: "database" });
}
});
Never check the database in the liveness probe
If the database goes down, every instance fails its liveness check and gets killed — turning an outage into an outage with no servers. Liveness asks about this process; readiness asks about this process’s ability to serve.
Shutting down gracefully
A deploy sends SIGTERM and waits — 30 seconds on most platforms — before sending SIGKILL. Everything above happens in that window. Without it, every deploy returns a handful of 502s to real users.
let shuttingDown = false;
process.on("SIGTERM", async () => {
shuttingDown = true; // 1. fail readiness — the balancer stops sending
await delay(5_000); // 2. let it notice
server.close(async () => { // 3. stop accepting, finish what is in flight
await db.end(); // 4. close the pool
process.exit(0);
});
setTimeout(() => process.exit(1), 30_000).unref(); // 5. never hang forever
});
A container that is not embarrassing
npm ci, nevernpm install, so the build is the lockfile.- Copy
package*.jsonfirst: the install layer is then cached until dependencies change. CMD ["node", …]directly —npm startputs npm between the platform and your signal handler.- A
.dockerignorewithnode_modules,.envand.git.
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # the lockfile, and no dev dependencies
COPY . .
ENV NODE_ENV=production
USER node # not root
EXPOSE 3000
CMD ["node", "src/app.js"] # no npm: it swallows signals
Deploying without downtime
- Start the new version, wait for it to be ready, move traffic, then stop the old one. That is a rolling deploy, and every platform does it for you — if readiness is honest.
- Migrations run before the new code, and must work with the old code still running. Add a column; do not rename one.
- Keep the previous version deployable. The fastest fix for a bad release is the old release.
And then watch it
- Logs as JSON on stdout, collected by the platform, searchable by request id.
- An uptime check from outside, on a real endpoint.
- Error tracking (Sentry or similar) — an exception you never hear about is a bug you never fix.
- Alerts on the two things users feel: error rate and latency.