Docker Compose Graceful Shutdown: Test Before Updating
Configure Docker Compose graceful shutdown with stop_grace_period, test signal handling safely, and avoid killing workers midway through a job during updates.
A Docker Compose update is also a shutdown test. Your replacement container can be perfectly healthy while the old one gets killed halfway through writing a file or finishing a background job.
I would rather test that boundary on a disposable worker than discover it during a password-manager upgrade. A green dashboard tells you a process is running. It does not tell you whether that process can leave politely.
The short version: give the application a documented stop signal, enough time to finish, and a process layout that delivers that signal. Then actually stop it and inspect what happened.
What Compose does when you stop a service
Compose asks the container to stop using its configured stop signal. In the usual case that is SIGTERM; an image’s STOPSIGNAL can supply a different default, and Compose’s stop_signal can override it.
The process gets a grace period to exit. When that expires, Docker can send SIGKILL, which the application cannot catch to finish cleanup. Compose documents a default grace period of ten seconds.
Ten seconds is not a promise that your export, upload, or queue job will finish. It is a deadline.
The official Compose service reference documents stop_grace_period and stop_signal. The Docker stop reference explains the image-level signal behavior.
Build a worker that we can safely interrupt
This example is deliberately not a production app. It simulates an in-flight job with a four-second cleanup step, prints a completion marker, and exits when it receives SIGTERM.
Use a new directory on a test machine with Docker Engine and the Compose plugin. There are no host ports, credentials, or persistent volumes here.
Create worker.py:
import signal
import time
stopping = False
def request_stop(signum, frame):
global stopping
stopping = True
print("Shutdown requested", flush=True)
signal.signal(signal.SIGTERM, request_stop)
print("Worker ready", flush=True)
while not stopping:
time.sleep(0.1)
print("Finishing current job", flush=True)
time.sleep(4)
print("Job finished; exiting cleanly", flush=True)
Now create compose.yaml in the same directory:
services:
worker:
image: python:3.13-alpine
init: true
command: ["python", "-u", "/app/worker.py"]
volumes:
- ./worker.py:/app/worker.py:ro
stop_signal: SIGTERM
stop_grace_period: 10s
The image tag is a readable example, not an immutable artifact. Pin a reviewed digest when reproducing this in a controlled environment, and schedule updates rather than freezing an interpreter forever.
I chose the list-form command so Python is launched directly, without a shell wrapper swallowing the signal. init: true adds a small init process that forwards signals and reaps child processes. It is useful plumbing, not an automatic save button for applications that ignore shutdown requests.
Run the successful stop test
Keep the same project name on every command so this exercise cannot accidentally target a different Compose stack:
docker compose -p shutdown-lab config -q
docker compose -p shutdown-lab up -d
docker compose -p shutdown-lab logs worker
Wait until the logs show Worker ready. Starting a container and immediately stopping it can test interpreter startup instead of the handler we just wrote.
Then stop only the disposable worker:
docker compose -p shutdown-lab stop worker
docker compose -p shutdown-lab logs worker
docker inspect --format '{{.State.ExitCode}}' \
"$(docker compose -p shutdown-lab ps -aq worker)"
Look for Shutdown requested, Finishing current job, and Job finished; exiting cleanly. With this script, a successful stop should also report exit code 0.
Those strings are expected markers from our script, not output from your actual application. A real service needs its own evidence: a drained queue, a completed transaction, or a documented shutdown message.
Make the failure happen on purpose
Start the worker again and wait for its new readiness message. Override the timeout for this one stop operation:
docker compose -p shutdown-lab start worker
docker compose -p shutdown-lab logs --tail 10 worker
# Wait for the new Worker ready message before continuing.
docker compose -p shutdown-lab stop -t 1 worker
docker compose -p shutdown-lab logs --tail 10 worker
docker inspect --format '{{.State.ExitCode}}' \
"$(docker compose -p shutdown-lab ps -aq worker)"
The worker needs four seconds, but this command gives it one. You should not see a new completion marker for that run; exit code 137 is consistent with a process killed by SIGKILL.
Do not diagnose every 137 as a shutdown timeout. Out-of-memory kills can produce that code too. Check .State.OOMKilled, timestamps, daemon events, and application logs before deciding what happened.
Clean up the experiment:
docker compose -p shutdown-lab down
No production service needs to be restarted to learn this lesson.
Apply the idea to one real service
Start with the application’s shutdown documentation, not a blanket stop_signal: SIGTERM pasted across your stack. Some images deliberately use another signal. Overriding it without checking can make shutdown worse.
Then set an explicit grace period on the service that needs it:
services:
worker:
# Keep your existing image, command, networks, and volumes.
stop_grace_period: 1m30s
This fragment belongs inside an existing service definition; it is not a complete runnable stack. Choose the timeout from the application’s maximum expected drain time and your maintenance budget, not from the number in this example.
Changing the Compose file alone does not update an existing container’s configuration. Apply the change during a maintenance window with docker compose up -d worker, then test the resulting container. Recreation itself stops the old container, so do not assume the new setting protects that first transition.
For workers, graceful shutdown usually means stop accepting new jobs, finish or safely release the current job, and exit. A longer timer cannot fix a worker that keeps taking new work forever. Nor does it turn a non-idempotent job into one that can safely be retried.
Three boundaries worth remembering
Health checks are separate. Our Docker healthcheck guide covers readiness and liveness. Passing a health check does not prove shutdown behavior.
Memory limits are separate too. A process can be killed before the grace period matters if it exhausts its allocation. See Compose resource limits for that boundary.
Backups still matter. A clean exit is not a restore test, and graceful shutdown does not protect against power loss or a host crash. Keep a tested backup strategy for persistent data.
Keep remote access separate from shutdown safety
If you administer servers while travelling, a commercial VPN can protect the network path from your laptop on an untrusted network. It does not configure Docker signals, replace SSH authentication, or prevent interrupted database writes.
The optional affiliate link below supports this site; the tutorial does not require a VPN subscription.
🚀NordVPN
An optional VPN for your admin laptop on untrusted networks, not a substitute for server hardening.
Affiliate link — we may earn a commission at no extra cost to you.
Your next update should have a stop test
I would test one important service before automating ten more upgrades. Pick a maintenance window, identify the expected shutdown markers, and verify both the exit status and the application-level result.
Start with the disposable worker above. If your real application cannot demonstrate an equally clear stopping path, that is the next thing to fix, not another dashboard to install.
Stay in the loop 📬
Get self-hosting tutorials, tool reviews, and infrastructure tips delivered to your inbox. No spam, unsubscribe anytime.
Join 0 self-hosters. Free forever.