Graceful Shutdown in Node.js and NestJS on Docker: SIGTERM, Draining, and Hook Order
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Headline: A graceful shutdown means a Node.js process that receives SIGTERM stops taking new work, finishes what is in flight, closes its database and Redis connections, and exits before the orchestrator's grace period ends. In a Dockerized NestJS app that takes an exec-form CMD, app.enableShutdownHooks(), and cleanup placed in the lifecycle hook that runs after the HTTP server drains.
Redeploying our containerized NestJS APIs used to cost a handful of 502s and, now and then, a queue job that ran twice. The logs showed no errors. The clue was in docker ps -a: the old containers ended with Exited (137). Exit code 137 is 128 + 9, meaning the process was ended by SIGKILL and our shutdown code never ran.
Key takeaways
• docker stop sends SIGTERM, waits 10 seconds by default, then sends SIGKILL; Kubernetes waits terminationGracePeriodSeconds, which defaults to 30 seconds.
• Exit code 137 (128 + 9) means SIGKILL: the process overran its grace period or was OOM-killed.
• Shell-form CMD, npm start, and Node.js running as PID 1 can each keep SIGTERM from reaching your code; use exec-form CMD ["node", "dist/main.js"] plus Docker's init: true.
• NestJS ignores termination signals until you call app.enableShutdownHooks(), then runs onModuleDestroy → beforeApplicationShutdown → HTTP server close → onApplicationShutdown.
• Close database pools and Redis clients in onApplicationShutdown, not onModuleDestroy, because onModuleDestroy runs while requests are still in flight.
What happens when Docker stops a Node.js container?
docker stop sends SIGTERM (or the image's STOPSIGNAL) to the container's PID 1, waits a grace period, and then sends SIGKILL. The default grace period is 10 seconds; docker stop --time and the Compose key stop_grace_period change it. SIGKILL cannot be caught, so anything unfinished is cut off: open HTTP responses, half-committed transactions, and queue jobs that were picked up but never acknowledged.
Kubernetes uses the same contract with terminationGracePeriodSeconds, 30 seconds by default, plus one race: a terminating pod is removed from Service endpoints in parallel with SIGTERM, not before it, so the pod can still receive new requests for a moment after shutdown starts.
The container's exit code tells you which path shutdown took:
• Exit code 0 — The process exited on its own. Your handler ran and called process.exit(0).
• Exit code 143 — 128 + 15 (SIGTERM). The process ended on SIGTERM; NestJS re-raises the signal after its hooks, so a clean Nest shutdown usually lands here.
• Exit code 137 — 128 + 9 (SIGKILL). The grace period expired or the OOM killer struck, and work was cut off mid-flight.
docker inspect reports "OOMKilled": true only in the out-of-memory case, which separates the two causes of exit code 137.
Why doesn't my Node.js app receive SIGTERM in Docker?
Docker delivers SIGTERM to PID 1, and three common setups put something other than a signal-aware Node.js process there:
• Shell-form CMD. CMD node dist/main.js runs as /bin/sh -c "node dist/main.js", so the shell becomes PID 1 and may not forward SIGTERM to Node.js.
• npm start as the container command. npm sits between Docker and your app; the Node.js Docker best-practices guide recommends running node directly.
• Node.js as PID 1. Linux ignores signals sent to PID 1 unless the process installed a handler for them, and Node.js was not designed to run as PID 1.
The fix is an exec-form command plus a tiny init process:
In the Dockerfile, we use the exec form, CMD ["node", "dist/main.js"], so Docker starts node directly with no shell in between. In docker-compose.yml, the api service sets init: true, which runs tini as PID 1, and stop_grace_period: 30s, which replaces Docker's 10-second default.
init: true in Compose (or docker run --init) runs tini as PID 1; tini forwards signals to Node.js and reaps zombie processes. It matters even when a handler is installed, because NestJS finishes shutdown by re-raising the signal, and a PID 1 process can ignore that re-raised SIGTERM too.
How do I enable graceful shutdown in NestJS?
NestJS does not listen for termination signals by default; you opt in by calling app.enableShutdownHooks() in main.ts. Without that call, onModuleDestroy, beforeApplicationShutdown, and onApplicationShutdown never run on SIGTERM — they only run when your own code calls app.close().
In main.ts, we call app.enableShutdownHooks() right after NestFactory.create(AppModule). We also register process.once('SIGTERM') with a 25-second setTimeout that logs an error and calls process.exit(1), and we call .unref() on that timer so it never keeps the process alive on its own.
The hard deadline is the part we skipped the first time. If a cleanup step hangs — a Redis client stuck reconnecting, a job that never ends — SIGKILL arrives with no log line explaining why. A timer set a few seconds below the grace period exits with a message instead, and .unref() stops the timer itself from holding the process open.
NestJS 11 re-raises the original signal after the shutdown hooks finish, which is why a clean shutdown exits with code 143. Recent NestJS 11 releases also accept app.enableShutdownHooks([], { useProcessExit: true }), which calls process.exit(0) instead — useful when an async logger such as a Pino transport needs the exit event to flush.
Which NestJS lifecycle hook should close the database connection?
Close database pools and Redis clients in onApplicationShutdown, the only NestJS shutdown hook that runs after the HTTP server has stopped. On a termination signal, NestJS 11 runs this sequence:
1. onModuleDestroy() runs immediately, while the HTTP server is still serving requests.
2. beforeApplicationShutdown(signal) runs after every onModuleDestroy() handler has settled.
3. The HTTP server closes: NestJS calls Node's server.close() through the HTTP adapter and waits for in-flight requests.
4. onApplicationShutdown(signal) runs after the server has closed.
Most NestJS examples disconnect the database in onModuleDestroy, which runs before step 3, so an in-flight request can lose its database mid-query; with node-postgres, every pool.query() after pool.end() throws. We moved each step to the hook that matches its moment:
Our ShutdownService implements all three hooks. onModuleDestroy sets an isShuttingDown flag. beforeApplicationShutdown awaits, in parallel, a sleep of DRAIN_DELAY_MS (default 0) and the BullMQ worker.close() call. onApplicationShutdown runs Promise.allSettled over pool.end() for node-postgres and redis.quit() for ioredis.
A GET /health/ready endpoint throws ServiceUnavailableException once isShuttingDown is true, so load balancers and Kubernetes readiness probes stop sending new traffic. BullMQ's worker.close() stops taking new jobs and waits for active ones — that ended our duplicate runs, because a job killed by SIGKILL is later detected as stalled and processed again. DRAIN_DELAY_MS only helps when something in front of the container routes by health; behind a reverse proxy that ignores health checks, we leave it at 0.
Why does my Node.js HTTP server hang on close()?
Node.js server.close() stops accepting connections and waits for open ones, and before Node.js 19 that included idle keep-alive sockets, which could hold shutdown open until keepAliveTimeout expired. Since Node.js 19, server.close() also closes idle connections, and Node.js 18.2 added server.closeIdleConnections() and server.closeAllConnections() for manual control. Without NestJS, the whole drain pattern is a few lines:
That version registers process.once('SIGTERM'), starts the same 25-second hard-deadline timer with .unref(), and calls server.close() with a callback that awaits pool.end() and then calls process.exit(0).
NestJS also offers NestFactory.create(AppModule, { forceCloseConnections: true }). With the Express adapter, that option calls socket.destroy() on every tracked socket at shutdown — including sockets with a request still in flight. On Node.js 24, the current LTS, we treat forceCloseConnections as a last resort, not a default.
How long should the shutdown grace period be?
The grace period must cover the drain delay, the slowest request or job you are willing to wait for, and connection cleanup, plus a margin. Docker's 10-second default rarely fits once a queue worker is involved, so our Compose files set stop_grace_period: 30s with the in-app hard deadline at 25 seconds. Any job that can outlast the grace period must be resumable or idempotent.
Graceful shutdown protects work already in flight; it does not create zero-downtime deploys. With one container, requests that arrive between the old container stopping and the new one becoming ready still fail at the proxy, so zero downtime needs a second instance serving traffic while the first one drains.
FAQ
Q: Why does my Docker container take 10 seconds to stop?
A: The process is not exiting on SIGTERM, so Docker waits out its 10-second default grace period and then sends SIGKILL. The usual causes are a shell-form CMD, npm start as the container command, or Node.js running as PID 1; use an exec-form CMD and init: true.
Q: What does exit code 137 mean for a Node.js container?
A: Exit code 137 is 128 + 9: the process was killed with SIGKILL, either because shutdown overran the grace period or because the kernel's OOM killer ended it. docker inspect shows "OOMKilled": true only in the second case.
Q: Is enableShutdownHooks() on by default in NestJS?
A: No. NestJS runs its shutdown lifecycle hooks on a termination signal only after app.enableShutdownHooks() is called; otherwise they run only when your code calls app.close().
Q: Does graceful shutdown give me zero-downtime deploys?
A: No. Graceful shutdown lets in-flight requests and jobs finish; zero-downtime deploys also need another healthy instance serving new traffic while the old one drains.
Further Reading
Backend Engineering
The Transactional Outbox Pattern in Postgres and NestJS: How We Ended Dual Writes Between the Database and the Queue
We replaced the save-then-publish dual write in our NestJS services with a transactional outbox in Postgres: the event row commits with the business change, a SKIP LOCKED relay publishes it, and consumers deduplicate by event ID. Here is the design, the failure modes it closes, and how we operate it.
Backend Engineering
Why CORS Breaks Between Next.js and NestJS: Our Field Notes on Preflights, Credentialed Cookies, and Proxying Instead
Every time we split a Next.js frontend from a NestJS API, we hit the same three CORS failures: rejected preflights, a wildcard origin that breaks with cookies, and sessions that never arrive. Here is what each CORS header actually does, the allowlist config we ship, and when we proxy /api through a Next.js rewrite instead.