Graceful Shutdown: Why Your Container Is Dying Badly (And How to Fix It)
You've deployed your NestJS app. Kubernetes is scaling it down. Requests are being dropped. Users are getting 502 errors. The logs show nothing useful — the process just disappeared.
Sound familiar?
The culprit, more often than not, is a missing graceful shutdown. And to understand why it fails, we first need to talk about something most backend developers rarely think about: PID 1 and Linux signals.
First, Let's Talk About Processes and PIDs
Every time Linux starts a program, it assigns it a Process ID — a PID. It's just a number, but it's how the OS keeps track of what's running.
PID 1 → your app (or sh, or npm — more on this later)
PID 2 → a child process spawned by PID 1
PID 3 → another child
...
PID 1 is special. On a regular Linux machine, it's systemd or init — the grandfather of all processes, the one the kernel starts first when the system boots. Every other process is a descendant of PID 1.
In a Docker container, whatever your CMD starts becomes PID 1. This is the process the container lives and dies by. When PID 1 exits, the container stops — and all other processes inside it are killed immediately, no questions asked.
This detail will matter a lot in a minute.
Linux Signals: Knocking on a Process's Door
Signals are how the OS (or one process) communicates with another. Think of them as short, predefined messages — not payloads, just notifications. A process receives a signal and can choose what to do with it.
You've been using signals your whole career without knowing it:
- You hit
Ctrl+Cin a terminal? That sends SIGINT to the running process. - You run
kill 1234? That sends SIGTERM to process 1234. - A process is completely frozen and won't die? You reach for
kill -9 1234— SIGKILL.
Here are the three signals that matter for shutdown:
| Signal | Number | Meaning | Can the process catch it? |
|---|---|---|---|
SIGTERM | 15 | "Please shut down cleanly" | Yes |
SIGINT | 2 | "You were interrupted" (Ctrl+C) | Yes |
SIGKILL | 9 | "Die right now, no negotiation" | No |
SIGTERM is the polite knock on the door. SIGKILL is kicking it down.
A well-behaved process catches SIGTERM, wraps up what it's doing, and exits on its own terms. A poorly behaved one ignores it — and eventually gets SIGKILL'd by the OS with no chance to clean up.
What Actually Happens When You Run docker stop
docker stop mycontainer
│
├─ Sends SIGTERM to PID 1 inside the container
│ │
│ └─ Process has 10 seconds to exit cleanly
│
└─ After 10 seconds: SIGKILL. The container is force-killed.
That 10-second window is your opportunity. If your app catches SIGTERM and uses those seconds wisely, you get a graceful shutdown. If it ignores the signal — or never receives it — you get a hard kill.
The same flow happens in Kubernetes when a pod is terminated, during rolling deployments, or when an autoscaler spins down a node.
The PID 1 Problem Nobody Warns You About
Here's the trap that catches most teams: the way you write your CMD in the Dockerfile determines whether your app ever receives SIGTERM.
Docker has two forms of CMD, and they behave very differently.
Shell form wraps your command in /bin/sh -c automatically:
CMD npm run start:prod
# Docker runs this as: /bin/sh -c "npm run start:prod"
Exec form runs your command directly, no shell involved:
CMD ["node", "dist/src/main.js"]
Who ends up as PID 1 — and whether your app ever receives SIGTERM — depends entirely on which form you use and what you point it at.
Who becomes PID 1?
| CMD form | PID 1 | Node's PID | Gets SIGTERM? |
|---|---|---|---|
["node", "main.js"] | node | 1 | ✅ Yes |
["npm", "run", "start:prod"] | npm | 2+ | ❌ No |
npm run start:prod | sh | 3+ | ❌ No |
sh -c "exec npm run start:prod" | npm | 2+ | ❌ No |
sh -c "exec node main.js" | node | 1 | ✅ Yes |
Let's break down why each one behaves this way.
CMD ["node", "dist/src/main.js"] — exec form pointing directly to node. No shell involved, so node is PID 1 and receives SIGTERM directly. ✅
CMD ["npm", "run", "start:prod"] — exec form, but npm is the entry point. npm spawns node as a child process. SIGTERM hits npm, npm does not forward it to node, and node gets killed.
PID 1 → npm
PID 2 → node (child of npm)
CMD npm run start:prod — shell form. Docker wraps it in sh -c, so sh becomes PID 1, spawning npm, which spawns node. SIGTERM hits sh, sh doesn't forward it, and neither npm nor node ever know what happened.
PID 1 → sh
PID 2 → npm (child of sh)
PID 3 → node (child of npm)
CMD sh -c "exec npm run start:prod" — exec replaces sh with npm, so npm becomes PID 1. But npm still spawns node as a child, so we have the same signal forwarding problem as above.
PID 1 → npm (sh was replaced by npm via exec)
PID 2 → node (child of npm)
CMD sh -c "exec node dist/src/main.js" — exec replaces sh with node directly, making node PID 1. Equivalent to exec form. ✅
PID 1 → node
The rule is simple: node must be PID 1 to receive SIGTERM directly. This is why exec form is not just a style preference — it's a correctness issue. The only two ways to guarantee it are:
CMD ["node", "dist/src/main.js"] # exec form — preferred
CMD sh -c "exec node dist/src/main.js" # when you need shell features
What Graceful Shutdown Actually Means
Now that you understand signals, graceful shutdown is simple to define:
Your app catches SIGTERM, stops accepting new work, finishes what it's doing, cleans up, and exits.
Without it, here's what happens during a deployment or scale-down:
- In-flight HTTP requests are dropped mid-response. Clients get connection reset errors.
- Active database transactions are abandoned — potentially leaving data in an inconsistent state.
- Message queue jobs are lost without being re-queued, causing silent data loss.
- WebSocket connections are severed without a close handshake.
- Logs and metrics may not be fully flushed.
With it, your app behaves like a responsible adult: it finishes the conversation it's in, closes its tabs, and logs out.
Triggering Graceful Shutdown
docker compose down triggers graceful shutdown
docker compose down
│
└─ sends SIGTERM to PID 1 inside each container
│
├─ if PID 1 is node ✅
│ └─ node receives SIGTERM
│ └─ NestJS enableShutdownHooks() catches it
│ └─ onModuleDestroy() runs (close DB, queues, etc.)
│ └─ exits cleanly
│
└─ if PID 1 is sh/npm ❌
└─ SIGTERM is swallowed
└─ node never knows
└─ after 10 seconds → SIGKILL
└─ dirty shutdown, no cleanup
Also triggers graceful shutdown in these situations:
docker stop <container>Ctrl+Cin the terminal where compose is running (sends SIGINT, NestJS handles that too)- Kubernetes terminating a pod during rolling deployment
- Autoscaler spinning down a node
Implementing It in NestJS
NestJS makes this straightforward. The framework has a built-in lifecycle event system designed exactly for this.
Step 1: Enable shutdown hooks in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// This tells NestJS to listen for SIGTERM and SIGINT
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();
With enableShutdownHooks(), when NestJS receives SIGTERM, it stops the HTTP server from accepting new connections, waits for in-flight requests to complete, then fires the lifecycle hooks so your services can clean up.
Step 2: Clean up in your services
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class DatabaseService implements OnModuleDestroy {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleDestroy() {
// Called automatically during shutdown
await this.dataSource.destroy();
console.log('Database connection closed.');
}
}
You can implement OnModuleDestroy in any injectable service. Common cleanup tasks:
- Database connections — close the connection pool
- Message queue consumers — stop consuming, finish the current message, unsubscribe
- Cache clients — flush pending writes, close the connection
- Third-party SDKs — flush telemetry, close open streams
Step 3: Use exec form in your Dockerfile
This is the final piece. Without it, all the above code is useless because your app never receives the signal.
# ✅ Correct - node is PID 1, receives SIGTERM directly
CMD ["node", "dist/src/main.js"]
# ❌ Wrong - sh is PID 1, swallows the signal
CMD npm run start:prod
A Real-World Scenario: Rolling Deployment
Imagine you push a new version of your API. Kubernetes initiates a rolling deployment:
- It starts a new pod with the updated image.
- Once the new pod is healthy, it terminates the old one.
- It sends
SIGTERMto the old pod's PID 1.
Without graceful shutdown:
- The old pod gets killed immediately (or after 10 seconds of timeout).
- Any requests that were being processed on that pod are dropped.
- Users see errors during the deployment window.
With graceful shutdown:
- The old pod receives
SIGTERM. - It stops accepting new requests (the load balancer has already been told to stop routing to it).
- It finishes the 3 in-flight requests it was handling — takes about 800ms.
- It closes the database connection pool.
- It exits cleanly.
- Users experience zero downtime.
The difference between these two scenarios is a few lines of code and one correct CMD.
Docker Compose: The command Gotcha
If you're running locally with Docker Compose, the same rules apply. Be deliberate about signal forwarding:
# ❌ Shell form — sh is PID 1, signals may not reach your app
command: npm run start:dev
# ✅ Exec workaround — exec replaces sh with your process
command: sh -c "exec npm run start:dev"
# ✅ Best option if no shell features needed
command: ["node", "dist/src/main.js"]
Quick Checklist
Before you ship, ask yourself:
- Is my Dockerfile
CMDin exec form["node", "dist/src/main.js"]? - Have I called
app.enableShutdownHooks()inmain.ts? - Do my services implement
OnModuleDestroyto close DB connections, queue consumers, and other resources? - Is my Kubernetes
terminationGracePeriodSecondsset high enough to cover the worst-case shutdown time? - Have I tested this locally by running
docker stopand watching the logs?
Wrapping Up
Graceful shutdown is one of those things that feels optional until it isn't. In development, where you're the only user and deployments are manual, a hard kill is annoying at worst. In production, with real users, real transactions, and automated deployments firing every few hours — it's the difference between a smooth release and a support ticket.
The underlying concepts — PID 1, SIGTERM, signal forwarding — sound intimidating, but they boil down to one thing: make sure your app is the one receiving the shutdown signal, and give it a chance to exit on its own terms.
Everything else follows from that.