open atlas
↑ Back to track
Node.js, zero to senior NODE · 08 · 02

Containerizing Node: PID 1, signals, and slim images

A Node process running as PID 1 ignores SIGTERM by default, so deploys hang the full grace period then hard-kill. Run node in exec form behind an init, build a multi-stage slim non-root image, and add a healthcheck and .dockerignore.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Every deploy of a checkout service took exactly thirty seconds longer than it should, and a thin slice of requests 502’d each time. The team blamed Kubernetes. The actual culprit was one line in the Dockerfile: CMD npm start. npm ran as PID 1, forked node as a child, and when the rollout sent SIGTERM, npm — with no signal handler and no forwarding — let it evaporate. Node never heard the shutdown, kept holding open connections, and Kubernetes waited out the full terminationGracePeriodSeconds before SIGKILL tore the pod down mid-request. The fix was four characters of JSON: ["node","server.js"]. The image had been quietly fighting the orchestrator on every single release.

PID 1 is special, and Node is not built for it

Inside a Linux container the first process started is PID 1, and the kernel treats PID 1 unlike any other process. Two rules bite Node specifically. First, PID 1 gets no default signal handlers — for a normal process the kernel installs a default action for SIGTERM (terminate), but for PID 1 there is no default, so a SIGTERM with no explicit handler is simply ignored. Second, PID 1 must reap zombies: when a child exits, its parent must wait() on it, and orphaned children are re-parented to PID 1. If PID 1 never reaps, dead children accumulate as zombies in the process table.

Node does install its own SIGTERM listener path only if you add one — but the deadly variant is starting Node indirectly. CMD npm start makes npm PID 1; npm spawns node as a child and does not forward signals to it. So the orchestrator’s SIGTERM hits npm (which ignores it as PID 1) and never reaches your application. The container appears to hang on shutdown.

# ❌ npm is PID 1; SIGTERM dies at npm, node never hears it
CMD npm start

# ❌ shell form: /bin/sh -c "node server.js" → sh is PID 1, also no forwarding
CMD node server.js

# ✅ exec form: node IS PID 1, your in-app SIGTERM handler runs
CMD ["node", "server.js"]

The shell form CMD node server.js is the same trap wearing a different hat: Docker wraps it as /bin/sh -c "node server.js", so sh becomes PID 1 and node is its child — signals stop at the shell. Always use the exec form (the JSON array) for your server so node is PID 1 directly.

Handle SIGTERM, or put an init in front of it

There are two complementary fixes, and production Node usually wants both.

The first is to run node directly (exec form) and handle SIGTERM in the app for graceful shutdown: stop accepting new connections, finish in-flight requests, close the DB pool, then exit. This is what turns a hard kill into a clean drain.

const server = app.listen(3000);

process.on("SIGTERM", () => {
  // stop taking new work, drain what's open, then exit cleanly
  server.close(() => {
    pool.end().then(() => process.exit(0));
  });
});

The second is to run an init as PID 1 so something reaps zombies and forwards signals even if your code forgets. Docker ships one: docker run --init injects tini as PID 1, which forwards SIGTERM to your node child and reaps orphans. You can also bake tini/dumb-init into the image as the ENTRYPOINT. An init matters most when your process spawns children (a build tool, a child_process, a wrapper script) — without one, those orphans become zombies.

Why this works

Why not just always rely on the init and skip the in-app handler? Because tini forwards the signal — it doesn’t drain your connections for you. Without a SIGTERM listener, node receives the forwarded signal and exits immediately, dropping in-flight requests. The init guarantees node gets the signal; your handler decides what graceful means. Init for delivery and reaping, app handler for drain — they solve different halves.

Multi-stage builds and a slim, non-root base

A single-stage image bakes your compiler, dev dependencies, and full toolchain into what you ship. A multi-stage build splits that: a builder stage runs npm ci and the build, and a thin runtime stage copies only the artifacts it needs.

# ---- builder ----
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                      # full deps for the build
COPY . .
RUN npm run build               # → /app/dist

# ---- runtime ----
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev           # prod deps only
COPY --from=builder /app/dist ./dist
USER node                       # the official image ships a non-root 'node' user
CMD ["node", "dist/server.js"]

Two details earn their keep. Copy package*.json and run npm ci before copying source. Docker caches each layer by its inputs; if the dependency layer’s inputs (the lockfile) don’t change, the whole npm ci layer is reused from cache and your slow install is skipped — only a source change invalidates the cheap COPY . . below it. (Layer caching is covered in depth in the deployment track; here the Node-specific point is lockfile before source.) And pick a slim base: a full node image is roughly 1 GB, while node:slim is a few hundred MB and a distroless Node runtime is in the tens of MB — a smaller image pulls faster and exposes a far smaller CVE surface because there’s no shell or package manager to attack.

PitfallSymptom in productionFix
CMD npm start as PID 1SIGTERM dies at npm; deploys hang ~30s then SIGKILL drops requestsExec form CMD [“node”,“server.js”]
Shell-form CMD node server.jssh is PID 1, swallows the signal, zombies pile upExec-form CMD and/or —init / tini
Runs as rootA code RCE owns the container; bigger blast radiusUSER node (non-root)
Single-stage image~1 GB image, slow pulls, huge CVE surface, dev deps shippedMulti-stage + slim/distroless runtime
No healthcheck, .env in imageDead pods stay in rotation; secrets leak in image layersHEALTHCHECK / probes + .dockerignore

Hardening: non-root, healthcheck, and .dockerignore

Three cheap moves separate a toy image from a production one. Run as non-root: the official node image already ships an unprivileged node user, so a single USER node line means a code-execution bug can’t trivially own the container. Add a healthcheck so the orchestrator knows when the process is actually serving — a Docker HEALTHCHECK curling a /health route, or in Kubernetes a livenessProbe/readinessProbe hitting /health and /ready; without one, a wedged process stays in the load-balancer rotation. Add a .dockerignore listing node_modules, .git, and .env: it keeps the build context small (faster builds) and, critically, prevents a committed .env from being copied into an image layer where the secret lives forever, recoverable by anyone who pulls the image.

# .dockerignore
node_modules
.git
.env
*.log

On Kubernetes the numbers tie back to PID 1: when a pod is deleted the kubelet sends SIGTERM, waits terminationGracePeriodSeconds (default 30s), and only then sends SIGKILL. If node never receives the SIGTERM (npm/shell PID 1) or has no handler, every deploy burns the full 30 seconds and then hard-kills in-flight work — exactly the hook’s slow, lossy rollout.

Pick the best fit

You want a containerized Node service to shut down gracefully on deploy and never accumulate zombie children. Which PID-1 strategy is the production default?

Quiz

Why does CMD npm start cause deploys to hang and then drop in-flight requests?

Quiz

Why prefer exec-form CMD (the JSON-array form, node + server.js) with USER node over shell-form CMD running as root?

Order the steps

Order the lines of a hardened multi-stage runtime stage so dependency caching works and the image is safe:

  1. 1 FROM node:22-slim AS runtime (small, slim base)
  2. 2 ENV NODE_ENV=production
  3. 3 COPY package*.json ./ then RUN npm ci --omit=dev (deps before source → cached layer)
  4. 4 COPY --from=builder /app/dist ./dist (only the built artifacts)
  5. 5 USER node (drop to the non-root user)
  6. 6 CMD ["node","dist/server.js"] (exec form, node is PID 1)
Recall before you leave
  1. 01
    What is special about PID 1 in a container, and why does CMD npm start break graceful shutdown because of it?
  2. 02
    How does a multi-stage Dockerfile shrink a Node image and its CVE surface, and why must npm ci come before COPY of the source?
Recap

A container’s first process is PID 1, and the kernel gives PID 1 no default signal handlers and makes it responsible for reaping zombies — neither of which Node was designed for. CMD npm start makes npm PID 1, which ignores SIGTERM and never forwards it to the node child; shell-form CMD node server.js makes /bin/sh PID 1 with the same effect. Either way node never hears the shutdown, so on Kubernetes the kubelet waits the full terminationGracePeriodSeconds (default 30s) and then SIGKILLs mid-request, hanging every deploy and dropping in-flight work. The fix is exec-form CMD ["node","server.js"] so node is PID 1 and receives the signal, an in-app SIGTERM handler that stops accepting connections and drains before exiting, and an init (--init / tini) to forward signals and reap orphans. Wrap that in a multi-stage build — a fat builder running npm ci and the build, a slim or distroless runtime copying only dist and npm ci --omit=dev production deps — and copy package*.json + npm ci before the source so the dependency layer stays cached. Finish with USER node for non-root, a HEALTHCHECK or liveness/readiness probes, and a .dockerignore (node_modules, .git, .env) so the build context stays lean and no secret is ever baked into a layer. Now when you see a deploy that hangs for exactly terminationGracePeriodSeconds and then drops requests, you’ll know it’s the PID 1 signal trap — and you’ll know the four-character fix.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.