A container that takes ten seconds to stop is telling you something. Your application never saw the shutdown request, Docker gave up waiting and killed it. In development that’s an annoyance. In production it means in-flight requests get cut off on every deploy, queue messages get processed twice, and files are left half-written.
This guide runs a tiny Node.js server through four variations to show where the signal goes and where it gets lost.
What docker stop actually does
The sequence is short:
- Docker sends
SIGTERMto the container’s PID 1. If the image setsSTOPSIGNAL(nginx usesSIGQUIT, for example), it sends that instead. - It waits for the stop timeout. For Linux containers the documented default is 10 seconds; you can change it per command with
docker stop -t 30, per container with--stop-timeout, or in Compose withstop_grace_period. - If PID 1 is still alive, Docker sends
SIGKILL, which can’t be caught. Everything else in the container dies with it.
docker kill skips straight to step 3 (or sends whatever --signal you pass). docker rm -f does the same. Ctrl+C on an attached docker run sends SIGINT instead of SIGTERM, which is why an app sometimes behaves differently in your terminal than under an orchestrator.
The exit code tells you which ending you got. A process killed by a signal exits with 128 plus the signal number, so 143 is SIGTERM (15) and 137 is SIGKILL (9). A clean process.exit(0) from a handler gives 0.
The PID 1 rule that catches everyone
Here’s the test server. It only installs a SIGTERM handler when HANDLE=1:
const http = require('http');
const server = http.createServer((req, res) => res.end('ok\n')).listen(3000);
console.log('listening, pid', process.pid);
if (process.env.HANDLE === '1') {
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server');
server.close(() => { console.log('closed, exiting'); process.exit(0); });
});
}Built with exec form, so node itself is PID 1:
FROM node:22-bookworm
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]Without the handler, and a 6-second timeout to keep the test quick:
$ docker run -d --name s1 sig
$ time docker stop -t 6 s1
real 6.25
$ docker inspect -f '{{.State.ExitCode}}' s1
137On a normal machine, a Node process with no handler dies instantly on SIGTERM. Inside a container it ignored the signal completely and sat there until the SIGKILL. The reason is a Linux rule about PID 1: the kernel doesn’t apply the default action of a signal to the init process of a PID namespace. If PID 1 hasn’t installed a handler, the signal is dropped. That’s protection for a real init system, and it’s exactly wrong for an application that assumes “default action means exit”.
Python, Java, Go and Node all behave this way if you rely on defaults. Some runtimes install handlers for you (the JVM does for SIGTERM and runs shutdown hooks), but many don’t, so “it stops fine on my laptop” doesn’t prove anything about the container.
With HANDLE=1 the picture changes:
$ docker run -d --name s2 -e HANDLE=1 sig
$ docker stop s2 # returns in well under a second
$ docker logs s2
listening, pid 1
SIGTERM received, closing server
closed, exiting
$ docker inspect -f '{{.State.ExitCode}}' s2
0server.close() stops accepting new connections and waits for open ones to finish. That’s the actual point of graceful shutdown: drain the work, flush logs, commit offsets, close the database pool, then exit.
How a shell in front of your app swallows the signal
Now the same handler-enabled app, but with shell form:
CMD node server.js$ docker run -d --name s3 -e HANDLE=1 sig-shell
$ docker logs s3
listening, pid 8
$ docker exec s3 ps -o pid,comm
PID COMMAND
1 sh
8 node
15 psNode is now PID 8, a child of /bin/sh -c. docker stop signals the shell, the shell (dash on Debian) has no reason to pass SIGTERM along, and the app’s carefully written handler never runs. The container ends with 137 after the full timeout. The log line “SIGTERM received” never appears, which is the quickest way to spot this in a real incident.
Some shells, bash among them, will exec a lone simple command and so happen to get this right. Don’t build on that. Use exec form, and if you need a wrapper script, finish it with exec "$@" so the app replaces the shell (the entrypoint note shows this).
The same trap appears one level up: npm start runs your server as a child of npm, and process managers like yarn or nodemon add their own layer. In a production image, run node server.js directly.
Gotcha
Exit 137 is also what you see when the kernel OOM killer ends a container. Check
docker inspect -f '{{.State.OOMKilled}}'before assuming it was a slow shutdown.
When an init process is the right fix
Sometimes you can’t easily add a handler, for instance a third-party binary, or a container that legitimately runs several processes. Docker can inject a minimal init (tini) as PID 1 with --init, or init: true in Compose:
$ docker run -d --name s4 --init sig # no handler in the app
$ docker stop s4 # returns in well under a second
$ docker inspect -f '{{.State.ExitCode}}' s4
143
$ docker logs s4
listening, pid 7tini sits at PID 1 and forwards the signal to the app, which is now an ordinary process, so the default action applies and it dies with SIGTERM (hence 143). tini also reaps zombie child processes, which matters for apps that spawn subprocesses. What --init doesn’t do is make the shutdown graceful: open requests were still dropped. A real handler is the only thing that drains work. The note on init processes goes further into reaping.
Picking the timeout
Ten seconds is a default, not a recommendation. Set the grace period to slightly more than your longest legitimate drain: a web server with a 30-second request timeout needs at least 30 seconds, a worker that processes 2-minute jobs needs either a longer grace period or the ability to put the job back on the queue. Setting it to five minutes “to be safe” just slows every deploy. Behind a load balancer there’s a second concern, which is that the balancer should stop sending traffic before the app stops accepting it; that’s why many services fail their readiness check first and then begin draining.
Interview practice
A team’s Python worker runs as CMD python worker.py in a container. Deploys take 10 seconds per replica, and they occasionally see the same queue message processed twice. They’ve added signal.signal(signal.SIGTERM, handler) to the code but the handler’s log line never appears. What’s going on, and what would you change?
Answer
The shell form means PID 1 is /bin/sh -c "python worker.py" and Python is a child. docker stop sends SIGTERM to the shell, which doesn’t forward it, so the Python handler never fires. Docker waits the 10-second default and sends SIGKILL. That explains both the fixed 10-second deploy delay and the duplicates: the worker is killed mid-message, before it acknowledges, so the broker redelivers the message to another replica.
Change the instruction to exec form, CMD ["python", "worker.py"], so Python is PID 1 and receives the signal. Confirm with docker exec <c> ps that python is PID 1, and that the container now exits with code 0 within a second or two of docker stop. Then make sure the handler does the right thing: stop pulling new messages, finish or requeue the current one, acknowledge, exit. Finally, set the grace period above the worst-case job time. Duplicates can still happen after a hard crash, so the consumer should also be idempotent; a good answer says that rather than claiming graceful shutdown alone solves it.
Keep going
- Docker interview questions
- Docker MCQ practice
- Docker init processes and zombie reaping
- Docker entrypoint and command overrides
- Official reference: docker container stop