
Every deploy replaces a running container. If you stop the old one and then start the new one, your users get errors for as long as nothing is listening on the port, and any request still running when the old process dies is cut off. How long that lasts depends on your runtime: how it reacts to SIGTERM, whether it drains open requests, and how fast the new version answers. We put a steady 100 requests a second on the same hello app in Python, PHP, Java, Spring Boot and .NET, redeployed it 3 times each way, and counted every failed request.
Short version: stop-then-start lost 32 to 212 requests per redeploy (a 0.3 to 2.2 s hole), and the runtimes with the best shutdown behaviour lost the most, because draining keeps the port busy while the old container refuses new connections. Starting the new container first, switching nginx over, waiting 1 second and only then stopping the old one lost 0 for every runtime, 1 slow request in 27 runs. Separately, Docker's 10 second stop timeout kills any request longer than that on every runtime, and two common setups (a shell-form CMD and php -S) ignore SIGTERM entirely.
How we measured
The apps are the ones from our cold start test plus one endpoint, GET /work?ms=N, which sleeps N milliseconds and stands in for a database call. A load generator fires requests on a fixed schedule (open loop, so it doesn't slow down when the server does) and counts every request that fails or takes longer than its 5 s timeout.
PHP's built-in server ran with 16 workers: with 4 it timed out under this load before any redeploy, which would have measured the server, not the deploy.
Stop-then-start: 32 to 212 failed requests per redeploy
This is what docker stop app && docker rm app && docker run ... -p 8080:8080 app:v2 does, and what most single-server deploy scripts do: the new container can't publish the same port until the old one is gone.
| Runtime | Failed (3 runs) | Of requests | Hole (median) | docker stop took | New one ready in |
|---|---|---|---|---|---|
| Spring Boot 3.5 (graceful, the default) | 212, 212, 201 | ~1,030 | 2.19 s | 1.13 s | 0.95 s |
| PHP 8.3 Apache | 176, 157, 194 | ~965 | 1.53 s | 1.40 s | 0.01 s |
| Python 3.12 uvicorn | 139, 135, 132 | ~960 | 1.39 s | 1.17 s | 0.21 s |
Spring Boot, server.shutdown=immediate | 129, 132, 127 | ~945 | 1.32 s | 0.21 s | 1.02 s |
| .NET 8 Kestrel | 121, 124, 117 | ~942 | 1.28 s | 1.08 s | 0.11 s |
| Java HttpServer + shutdown hook | 121, 116, 115 | ~937 | 1.25 s | 1.06 s | 0.09 s |
| Java HttpServer, no hook | 45, 64, 170 | ~910 | 0.64 s | 0.33 s | 0.09 s |
PHP 8.3 php -S + --init | 32, 36, 32 | ~840 | 0.31 s | 0.16 s | 0.00 s |
| PHP 8.3 php -S | 37, 30, 32 | ~1,840 | 0.29 s | 10.18 s | 0.00 s |
The count follows from the hole: requests lost ≈ request rate × hole. Python's 1.39 s at 100 requests a second is 139 requests; we counted 132 to 139. Scale it to your own traffic: a Spring Boot app at 20 requests a second loses about 20 × 2.2 = 44 requests per deploy, and with 5 deploys a day that's around 220 failed requests a day, each one a user who saw an error page or a failed API call.
Why the well-behaved runtimes lose the most
Spring Boot, Apache, uvicorn, Kestrel and the Java server with a hook all do the right thing on SIGTERM: stop accepting new connections, finish the requests already running, exit. Our slowest request was 1 s, so each took about 1.1 s to stop. During that second the old container refuses new connections and the new one can't bind the port yet. Graceful shutdown saves the in-flight requests, but in stop-then-start it makes the hole longer, not shorter.
The runtimes that die at once look better in the table only because the hole is shorter: Spring Boot with server.shutdown=immediate lost 129 instead of 212, and the Java server without a hook 64. But every request that was running at that moment got its connection closed mid-answer, which is worse for a payment or a form post than a refused connection the client can retry. Spring's immediate row is still 1.3 s because Spring takes 1 s to start; plain Java starts in 0.09 s.
php -S lost the fewest, for a bad reason: it ignores SIGTERM. It runs as PID 1, and the Linux kernel doesn't apply default signal actions to PID 1 of a namespace, so a process without its own handler keeps serving. docker stop waits its full 10 seconds and then sends SIGKILL, which cuts whatever was in flight. Every deploy took 10 s longer, and the run covered twice as many requests. With docker run --init (a tiny init as PID 1 that forwards signals) it stopped in 0.16 s and lost the same 32 to 36.
What each runtime does with SIGTERM
To see the shutdown itself, we sent one 3 second request, ran docker stop 0.5 s later and sent a new request 0.3 s into the stop.
| Setup | Request in flight | New request during the stop | docker stop took | Exit code |
|---|---|---|---|---|
| Python uvicorn, exec-form CMD | 200, waited for | refused | 2.5 s | 0 |
Python, shell-form CMD (sh -c is PID 1) | 200, kept serving | 200 (still serving) | 10.2 s, SIGKILL | 137 |
Python, shell form + --init | cut off | refused | 0.33 s | 143 |
| php -S (PID 1) | 200, kept serving | 200 (still serving) | 10.1 s, SIGKILL | 137 |
php -S + --init | cut off | refused | 0.33 s | 143 |
| php:8.3-apache (image stops with SIGWINCH) | 200, waited for | closed | 3.15 s | 0 |
php:8.3-apache, --stop-signal SIGTERM | cut off | refused | 0.35 s | 0 |
| Java HttpServer, no shutdown hook | cut off | closed | 0.41 s | 143 |
Java HttpServer + hook server.stop(25) | 200, waited for | closed | 2.55 s | 143 |
| Spring Boot 3.5 default | 200, waited for | closed | 2.6 s | 143 |
Spring Boot, server.shutdown=immediate | cut off | refused | 0.37 s | 143 |
| .NET 8 Kestrel | 200, waited for | closed | 2.55 s | 0 |
Three things stand out. A shell-form CMD uvicorn app:app ... puts /bin/sh at PID 1; it doesn't pass SIGTERM on, so the app never hears it and dies by SIGKILL 10 s later. Use the exec form, CMD ["uvicorn", ...]. The official php:*-apache images set STOPSIGNAL SIGWINCH, Apache's graceful stop; plain SIGTERM makes Apache kill its children and drop running requests, so don't override it. And the JVM exits on SIGTERM straight away unless you register a shutdown hook; Spring Boot registers one and has drained by default since 3.4.
The 10-second trap
We repeated the test with a 15 second request. Python, Spring Boot, .NET, Apache and Java with its hook were all killed at 10.1 to 10.3 s with exit code 137, and the request was lost. Each runtime would have waited longer on its own (.NET's HostOptions.ShutdownTimeout and Spring's timeout-per-shutdown-phase are both 30 s, uvicorn waits without limit), but docker stop sends SIGKILL after 10 seconds unless you tell it otherwise.
| Where | Setting for a 30 s grace period |
|---|---|
| docker run | docker run --stop-timeout 30 ... |
| docker stop | docker stop -t 30 app |
| Docker Compose | stop_grace_period: 30s |
| Kubernetes | terminationGracePeriodSeconds: 30 (the default) |
Set it longer than your slowest legitimate request, and keep the runtime's own limit just under it so the app exits on its own terms.
Start-then-switch: 0 failed with a 1 second pause
The fix for the hole is to never have a moment with nothing listening. Put a reverse proxy in front, start the new container next to the old one, wait until it answers, point the proxy at it, and only then stop the old one. On one server that's nginx and two container names, app-blue and app-green. We ran it two ways: stopping the old container right after nginx -s reload, and waiting 1 second first.
| Runtime | Failed, stop right after reload | Failed, wait 1 s then stop | Longest gap, wait 1 s |
|---|---|---|---|
| Python 3.12 uvicorn | 0, 3, 0 | 0, 0, 0 | 53 ms |
| PHP 8.3 Apache | 3, 7, 8 | 0, 0, 0 | 53 ms |
| PHP 8.3 php -S (16 workers) | 0, 0, 0 (10 s stops) | 0, 0, 0 | 52 ms |
PHP 8.3 php -S + --init | 18, 18, 18 | 0, 0, 0 | 53 ms |
| Java HttpServer, no hook | 95, 19, 16 | 0, 0, 1 | 53 ms |
| Java HttpServer + hook | 5, 7, 5 | 0, 0, 0 | 52 ms |
| Spring Boot 3.5 | 0, 7, 5 | 0, 0, 0 | 52 ms |
| Spring Boot, immediate | 12, 14, 15 | 0, 0, 0 | 54 ms |
| .NET 8 Kestrel | 4, 7, 6 | 0, 0, 0 | 52 ms |
Every error in the no-pause column was an nginx 502. nginx -s reload returns as soon as the master has read the new config; the old worker processes keep handling the connections they have, and still send them to the old upstream for a moment. Stop the old container in that moment and those requests fail. Graceful runtimes lost 0 to 8 because they still answered what was in flight; the ones that die at once lost 12 to 95. A 1 second pause fixed every runtime: 0 failed in 26 of 27 runs, and the one failure was a single 1 s request on the Java server with no shutdown hook. The longest gap between successful answers stayed at the normal 52 to 54 ms.
The pause covers requests shorter than the pause. For longer ones you still need graceful shutdown plus a stop timeout longer than the request, so use both: the pause for the proxy, the drain for the app.
The script
This is the script we tested. It needs a Docker network, nginx in a container called web on that network with its config mounted from the host, and an app listening on 8080. It took about 3.4 s per swap with a 2 s pause.
Run it as ./swap.sh myapp:v2; the next deploy goes back to blue. If the new container never answers, the script prints its logs, removes it and leaves the old one serving. Mount the directory, not the single file: sed -i writes a new file, and a single-file bind mount would keep showing nginx the old one. If your app needs a moment after its first 200 (caches, migrations), point the wait loop at a health endpoint that only says ready when it is.
Settings that make a restart safe, per runtime
| Runtime | Drains on SIGTERM by default? | What to set |
|---|---|---|
| Python (uvicorn) | Yes, if it gets the signal | Exec-form CMD. Optional --timeout-graceful-shutdown 25 to cap the wait. With gunicorn, --graceful-timeout (30 s default) |
| PHP php -S | No: ignores SIGTERM as PID 1 | docker run --init (or tini as the entrypoint); better, php-fpm or Apache for production |
| PHP Apache (official image) | Yes, via STOPSIGNAL SIGWINCH | Keep the image's stop signal; raise the stop timeout above your slowest request |
| Java, plain JDK server | No | Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(25))) |
| Spring Boot 3.4+ | Yes (graceful default) | spring.lifecycle.timeout-per-shutdown-phase=25s; on Boot 2.3-3.3 set server.shutdown=graceful |
| .NET 6+ | Yes, 30 s | HostOptions.ShutdownTimeout under the stop timeout (e.g. builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25))) |
| Any, in Docker | 10 s, then SIGKILL | --stop-timeout 30 / stop_grace_period: 30s |
A quick check for your own image: start it, send a slow request, run docker stop and look at the exit code with docker inspect -f '{{.State.ExitCode}}' app. 137 means SIGKILL: the app ignored SIGTERM or ran out of time. 0 or 143 means it handled the signal; whether in-flight requests survived depends on the drain.
What we'd do on a single server
Start-then-switch is not a big platform feature. It is one nginx container and a 20-line script, and it took the cost of a deploy from 1 to 2 seconds of errors to none in our test. Add graceful shutdown and a 30 s stop timeout and the slow requests survive too. If you keep stop-then-start, at least use the exec form or --init so SIGTERM arrives, and deploy when traffic is low: the lost requests scale with the rate.
The new container has to start before it can take over, so start time still matters, just not as downtime: see how long each runtime takes in our cold start test. For a while two copies of your app run side by side; our idle memory test shows what each one costs.
Sources: our measurements of 29 Oct 2026 (apps, load script and raw results kept with the test); Docker's docker container stop reference (SIGTERM, then SIGKILL after 10 s; STOPSIGNAL and --stop-signal) and the Compose stop_grace_period setting; the docker-library/php Apache Dockerfiles (STOPSIGNAL SIGWINCH); Apache httpd 2.4 Stopping and Restarting (TERM ends running requests, WINCH is graceful-stop); Spring Boot reference, Graceful Shutdown (default since 3.4, spring.io blog 24 Nov 2024); .NET HostOptions.ShutdownTimeout (30 s); JDK 21 HttpServer.stop(int); uvicorn settings (--timeout-graceful-shutdown); nginx controlling nginx (old workers finish their connections after a reload).
systemi builds and runs Python, Java, .NET, PHP and static apps in their own containers on one platform, with a PostgreSQL database per app and a subdomain.
See how deploys work

