Deploying

Put a server somewhere it stays up, and let it be replaced without dropping a request.

18of 18
≈ 11Min read
7Parts

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.

pick one; do not write your own
# 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

and mixing them up causes restart loops
// 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.

the sequence that drops nothing
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, never npm install, so the build is the lockfile.
  • Copy package*.json first: the install layer is then cached until dependencies change.
  • CMD ["node", …] directly — npm start puts npm between the platform and your signal handler.
  • A .dockerignore with node_modules, .env and .git.
Dockerfile
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.

Practice