My deploy script does docker compose down, then docker compose up -d. One afternoon I actually watched it instead of alt-tabbing away, and the down half sat there for about 20 seconds doing nothing visible. So I timed every container one at a time. docker stop takes 10 seconds on 5 of my 6 containers. Not roughly 10. Ten point something, every run, like a timer.
That number is the giveaway. It's not your app being slow to shut down. It's Docker giving up on your app and killing it.
TL;DR
-
docker stopsends SIGTERM, waits 10 seconds by default, then sends SIGKILL. A stop that always lands at ~10s means your process never reacted to SIGTERM. - Inside a container your process is usually PID 1, and Linux does not apply the default "terminate" action to PID 1. If PID 1 has no SIGTERM handler, the signal is simply dropped.
- Shell-form
CMD node server.jsruns as/bin/sh -c "node server.js". Depending on the shell,shcan end up as PID 1 and your app never sees the signal. - Fix it with exec-form
CMD ["node", "server.js"], a real SIGTERM handler in your app,exec "$@"in entrypoint scripts, ordocker run --init/init: truein Compose. - Check with
docker inspect -f '{{.State.ExitCode}}' <name>after a stop: 137 means it was SIGKILLed.
Why does docker stop take exactly 10 seconds?
Because 10 seconds is Docker's default grace period. docker stop sends the container's stop signal (SIGTERM unless the image says otherwise), starts a timer, and if the main process is still alive when the timer runs out, sends SIGKILL, which cannot be caught or ignored.
You can see the two endings in the exit code:
docker stop api docker inspect -f '{{.State.ExitCode}}' api # 137 -> 128 + 9 (SIGKILL: Docker gave up) # 143 -> 128 + 15 (SIGTERM, default action) # 0 -> your app caught SIGTERM and exited cleanly All five of my slow containers exited 137. Every single deploy, five processes were being killed mid-whatever-they-were-doing. Nobody noticed because "the deploy worked."
docker compose down uses the same 10-second default per container, and it stops services in dependency order, so a couple of layers of ignored signals stack up into the 20 seconds I was staring at.
Why does PID 1 ignore SIGTERM?
PID 1 is special in Linux. On a normal process, a signal with no handler gets its default action, and for SIGTERM the default is "die." For PID 1 of a PID namespace, the kernel skips that default. If the process hasn't explicitly installed a handler, SIGTERM does nothing.
On your laptop, PID 1 is systemd or launchd, which handle signals on purpose. In a container, PID 1 is whatever your CMD or ENTRYPOINT started. Your Flask app. Your Node worker. A bash script someone wrote in 2019.
Two concrete cases from my six:
- Python worker. CPython installs a handler for SIGINT (that's your
KeyboardInterrupt), but not for SIGTERM. As PID 1, a plain Python script ignores SIGTERM completely. - Node API. The official Node Docker guidance says it outright: Node wasn't designed to run as PID 1. My API had no
process.on('SIGTERM')handler, and it sat there for the full 10 seconds every time.
Run the same programs outside Docker and kill <pid> stops them instantly. That's why this one hides so well.
What is the difference between shell form and exec form CMD?
Shell form is the string version, and Docker wraps it in a shell. Exec form is the JSON array version, and Docker runs it directly.
# Shell form: Docker actually runs ["/bin/sh", "-c", "node server.js"] CMD node server.js # Exec form: Docker runs node directly as PID 1 CMD ["node", "server.js"] You don't have to guess which one an image uses:
docker inspect -f '{{json .Config.Cmd}}' api # ["/bin/sh","-c","node server.js"] <- shell form With shell form, whether sh stays around as PID 1 depends on the shell. Some shells replace themselves with the last simple command (an exec optimization), some don't, and the moment you write CMD cd /app && node server.js you've given most of them a reason to stick around. When sh is PID 1, it receives SIGTERM, has no handler, ignores it, and doesn't forward it to node. Your app never even hears about the shutdown.
Don't reason about it. Look:
docker exec api ps -o pid,comm # PID COMMAND # 1 sh # 7 node If PID 1 isn't your actual program, that's your 10 seconds. (On minimal images without ps, docker exec api cat /proc/1/cmdline works.)
Does npm start forward SIGTERM to my Node app?
Don't bet your shutdown on it. CMD ["npm", "start"] makes npm PID 1, and npm runs your script through yet another sh -c. That's two layers between Docker and the process that holds your open connections, and how well signals pass through them has varied over the years.
The fix is boring: call node directly.
CMD ["node", "server.js"] Same rule for yarn start, poetry run, pipenv run, and any other launcher. Package managers are for installing things, not for being PID 1.
How do I fix docker stop taking 10 seconds?
Use exec form, give PID 1 a SIGTERM handler, and if you can't control the process, put a tiny init in front of it. Here's what I changed, one container at a time.
1. Exec form everywhere. Three of my five slow containers were plain shell-form CMD lines. Switching to the JSON array was the whole fix for two of them.
2. A real SIGTERM handler. Exec form only gets the signal to your process. Your process still has to do something with it. For the Node API:
const server = app.listen(3000); process.on('SIGTERM', () => { server.close(() => process.exit(0)); // stop accepting, finish in-flight setTimeout(() => process.exit(1), 8000).unref(); // don't outlive the grace period }); For the Python worker:
import signal, sys def shutdown(signum, frame): # finish the current job, close connections sys.exit(0) signal.signal(signal.SIGTERM, shutdown) 3. exec "$@" in entrypoint scripts. One container had an entrypoint.sh that ran migrations and then started the server. The last line was python worker.py. Bash stayed as PID 1 and swallowed the signal. One word fixed it:
#!/bin/sh set -e python manage.py migrate exec "$@" exec replaces the shell with your program, so your program becomes PID 1 and receives signals directly.
4. --init for things you don't own. For a third-party image I didn't want to rebuild, Docker can inject a minimal init (tini) as PID 1. It forwards signals to your process and reaps zombie child processes as a bonus.
docker run --init some/image services: worker: image: some/image init: true 5. Change the signal or the timeout only when you mean it. Some programs want a different shutdown signal. The official nginx image sets STOPSIGNAL SIGQUIT because that's nginx's graceful shutdown. If your app genuinely needs longer than 10 seconds to drain, raise it with stop_grace_period: 30s in Compose or docker stop -t 30. Raising the timeout on a process that ignores SIGTERM just makes you wait longer for the same SIGKILL.
Why does this matter if the container stops anyway?
Because SIGKILL is not a shutdown, it's a power cut. No in-flight requests finish. No database transaction gets cleanly closed. No queue message gets acked or handed back. No log buffer gets flushed.
Kubernetes plays by the same rules, just with a 30-second default terminationGracePeriodSeconds. A pod whose PID 1 ignores SIGTERM burns the full 30 seconds on every rollout and then gets killed. Multiply that by replicas and rolling-update batches and your "slow deploys" might be nothing but ignored signals.
What happened to my six containers?
After the changes, all six stop in under a second and exit 0. My compose down went from about 20 seconds to about 2. The one container that was fast from the start was Postgres, whose official image ends its entrypoint with exec and whose server treats SIGTERM as a proper shutdown request. The database was the only thing in my stack that was already doing it right.
The full audit takes five minutes:
for c in $(docker ps --format '{{.Names}}'); do echo "$c: $(docker exec "$c" cat /proc/1/cmdline 2>/dev/null | tr '\0' ' ')" done Anything that prints sh -c, npm, or a script name instead of your real binary deserves a look.
So why does docker stop take 10 seconds?
docker stop takes 10 seconds because Docker sends SIGTERM, your container's PID 1 ignores it, and Docker waits out its default 10-second grace period before sending SIGKILL (exit code 137). PID 1 ignores SIGTERM when it has no handler installed, because Linux skips the default terminate action for a namespace's init process, and shell-form CMD, npm start, or an entrypoint script without exec can put a shell in that seat so your app never receives the signal. Switch to exec-form CMD ["your", "binary"], end entrypoint scripts with exec "$@", add a SIGTERM handler that drains and exits, or run with --init, and the stop drops from 10 seconds to almost nothing.
Written by the developer behind Preterview, an interview prep platform.


