PID 1, Signals & Graceful Shutdown
Every deploy, restart and scale-down stops containers. If your app stops cleanly, users never notice. If it does not, requests in progress fail, a half-written order stays in the database, and every docker stop takes an annoying ten seconds. The difference is how your container handles signals, the short messages Linux sends to a process, such as "please stop" (SIGTERM) and "stop now" (SIGKILL).
This lesson explains what Docker does when it stops a container, why the first process in a container is special, and the four fixes that make shutdown fast and safe: exec form, exec in entrypoint scripts, an init process, and a SIGTERM handler in your app.
What docker stop really does
When you run docker stop (or docker compose down, or a deploy replaces a container), Docker:
- Sends the container's stop signal to process 1 inside it. By default this is
SIGTERM. - Waits for the process to exit. The default grace period is 10 seconds on Linux (30 seconds for Windows containers).
- If it is still running, sends
SIGKILL, which the kernel enforces at once. The program cannot catch it, clean up or log anything.
The exit code tells you which ending happened: 143 (128 + 15) means the app exited because of SIGTERM, 137 (128 + 9) means it had to be killed. An app that handles SIGTERM itself often exits with 0.
Why PID 1 is special
The first process in a container gets process ID 1, the role an init system such as systemd plays on a normal Linux machine. The kernel treats PID 1 differently: signals that the process has no handler for are ignored instead of using their default action. On a normal machine, SIGTERM kills a program that has no handler. As PID 1 in a container, the same program just keeps running.
You can see this with sleep, which has no signal handlers:
docker run -d --name sleeper alpine:3.22 sleep 300
time docker stop sleepersleeper real 0m10.412s user 0m0.021s sys 0m0.016s
Docker waited the full 10 seconds and then killed it (the timing is traced and will vary slightly). The same thing happens to many apps that do not install their own SIGTERM handler, and to apps hidden behind a shell that does not pass signals on.
Fix 1 and 2: let the signal reach your app
The most common cause of slow stops is a shell in front of the app. Shell-form CMD npm start makes /bin/sh process 1, as you saw in CMD, ENTRYPOINT, ARG & Other Instructions. npm start adds another layer: npm itself starts Node.js as a child. Use exec form and start the runtime directly: CMD ["node", "server.js"].
Entrypoint scripts have the same trap. Many images use a small script to prepare things (wait for a database, render a config file) before starting the app. The script runs as PID 1, so it must replace itself with the app using exec:
#!/bin/sh
# docker-entrypoint.sh
set -e
if [ -n "$DB_PASSWORD_FILE" ]; then
DB_PASSWORD="$(cat "$DB_PASSWORD_FILE")"
export DB_PASSWORD
fi
python manage.py migrate --noinput
exec "$@"COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "hostel.wsgi"]"$@" is the CMD passed to the script. exec replaces the shell process with gunicorn, so gunicorn becomes PID 1 and receives SIGTERM directly. Without exec, the shell stays as PID 1, gunicorn runs as its child, and the stop signal never reaches it. COPY --chmod=755 makes the script executable during the copy.
Fix 3: add a tiny init process
Some programs do not handle signals well, or start child processes and never clean them up. Finished child processes that nobody collects stay in the process table as zombies. A small init process solves both problems: it runs as PID 1, forwards signals to your app and collects finished children.
Docker includes one. Add --init to docker run, or init: true in Compose:
docker run -d --name sleeper2 --init alpine:3.22 sleep 300
time docker stop sleeper2sleeper2 real 0m0.391s user 0m0.019s sys 0m0.014s
Now the init process (Docker's docker-init, which is based on the small open-source init called tini) is PID 1. It passes SIGTERM to sleep, which is no longer PID 1 and so dies immediately. Some images install tini themselves and use it as their ENTRYPOINT; the effect is the same.
Fix 4: handle SIGTERM in your app
Getting the signal is half the job. A graceful shutdown means: stop accepting new requests, finish the ones in progress, close database connections, then exit. In Node.js with Express:
const server = app.listen(3000, () => console.log("listening on 3000"));
function shutdown(signal) {
console.log(`${signal} received, closing server`);
server.close(async () => {
await pool.end();
console.log("clean exit");
process.exit(0);
});
setTimeout(() => process.exit(1), 8000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);server.close stops accepting new connections and calls back when open ones finish. pool.end() closes the MySQL connection pool. The timer is a safety net: if something hangs, the app exits by itself after 8 seconds, before Docker's 10-second SIGKILL. SIGINT is the signal Ctrl+C sends, so the same code works when you run the app in a terminal. Production servers such as gunicorn, uvicorn and PHP-FPM already handle SIGTERM gracefully, as long as it reaches them.
Tuning the stop signal and grace period
| Setting | docker run | Compose | Dockerfile |
|---|---|---|---|
| Signal to send | --stop-signal SIGQUIT | stop_signal: SIGQUIT | STOPSIGNAL SIGQUIT |
| Grace period | --stop-timeout 30 | stop_grace_period: 30s | (none) |
| Init process | --init | init: true | (none) |
Some programs use a different signal for a graceful stop. The official Nginx image, for example, sets STOPSIGNAL SIGQUIT, which Nginx treats as "finish current requests, then exit". Give long-running jobs, such as a worker that processes a 30-second PDF export, a grace period longer than their longest task. Kubernetes has the same idea with its own setting; see Rolling Updates & Rollbacks in the Kubernetes course.
Common mistakes
CMD npm startorCMD node server.jsin shell form. The signal stops at npm or/bin/sh. UseCMD ["node", "server.js"].- Entrypoint scripts without
exec "$@". The script stays PID 1 and the app never hearsSIGTERM. - Catching
SIGTERMand doing nothing. The app ignores the stop and is killed after 10 seconds anyway. Close the server and exit. - Cleanup that takes longer than the grace period. Raise
stop_grace_periodor make shutdown faster;SIGKILLcannot be handled. - Ignoring zombie processes in apps that start child processes (headless browsers, report generators). Use
--init.
Key points
docker stopsendsSIGTERM(or the image'sSTOPSIGNAL), waits 10 seconds on Linux, then sendsSIGKILL; exit code 137 means the app had to be killed.- PID 1 ignores signals it has no handler for, so a program with no handler, or a shell in front of the app, causes slow stops.
- Use exec-form
CMD, and end entrypoint scripts withexec "$@". --initorinit: trueadds Docker's tini-based init as PID 1 to forward signals and collect zombie processes.- Handle
SIGTERMin your app: stop accepting work, finish requests, close connections, exit before the grace period ends.
Exercise
Meera's container pdf-worker always takes 10 seconds to stop and its logs never show her "shutting down" message. Its Dockerfile ends with ENTRYPOINT ["/start.sh"], and start.sh contains two lines: echo "starting worker" and node worker.js. Find the cause, fix the script, and give the worker 45 seconds to finish its current job in Compose.
Show solution
#!/bin/sh
echo "starting worker"
exec node worker.jsservices:
pdf-worker:
image: ghcr.io/meera-nair/pdf-worker:1.1.0
stop_grace_period: 45sWithout exec, the shell running start.sh is PID 1 and does not pass SIGTERM to Node.js, so the handler never runs and Docker kills everything after 10 seconds. With exec, Node.js replaces the shell, receives the signal, prints its message and finishes the job within the 45-second grace period.
Quick Quiz
1. What does docker stop send first by default?
The app gets a chance to clean up before the forced kill.
2. Why does sleep 300 as PID 1 ignore docker stop?
Outside a container, the default action would kill it.
3. What does exec "$@" at the end of an entrypoint script do?
Without exec, the shell stays in front of the app.
4. Which option adds a small init process that forwards signals and collects zombies?
In Compose, the same setting is init: true.
Learn Docker & DevOps with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
