Bar chart of requests lost in one redeploy at 100 requests a second. Stop the old container, then start the new one: Spring Boot 212, PHP Apache 176, Python 135, .NET 121, Java HttpServer 64, php -S 32. Start the new one, switch nginx, wait 1 s, stop the old one: 0 for every runtime

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.

text
machine    Mac mini (Apple silicon), Docker Desktop, Docker Engine 29.8.2, linux/arm64 images
apps       Python 3.12 FastAPI 0.120 + uvicorn 0.38 (exec-form CMD)
           PHP 8.3: php -S (PHP_CLI_SERVER_WORKERS=16) and php:8.3-apache
           Java 21 JDK HttpServer, plain jar, 64 threads; Spring Boot 3.5.6 (Tomcat)
           .NET 8 minimal API (Kestrel); nginx 1.29-alpine as the proxy for the switch
load       100 requests/s, new connection each, 95% /work?ms=50, 5% /work?ms=1000
           ~950 requests per run, redeploy at second 4, 5 s client timeout
redeploy   same image swapped for itself, 3 runs per runtime and method
gap        longest time between two successful answers (normal: ~52 ms)
date       29 Oct 2026

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.

RuntimeFailed (3 runs)Of requestsHole (median)docker stop tookNew one ready in
Spring Boot 3.5 (graceful, the default)212, 212, 201~1,0302.19 s1.13 s0.95 s
PHP 8.3 Apache176, 157, 194~9651.53 s1.40 s0.01 s
Python 3.12 uvicorn139, 135, 132~9601.39 s1.17 s0.21 s
Spring Boot, server.shutdown=immediate129, 132, 127~9451.32 s0.21 s1.02 s
.NET 8 Kestrel121, 124, 117~9421.28 s1.08 s0.11 s
Java HttpServer + shutdown hook121, 116, 115~9371.25 s1.06 s0.09 s
Java HttpServer, no hook45, 64, 170~9100.64 s0.33 s0.09 s
PHP 8.3 php -S + --init32, 36, 32~8400.31 s0.16 s0.00 s
PHP 8.3 php -S37, 30, 32~1,8400.29 s10.18 s0.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.

SetupRequest in flightNew request during the stopdocker stop tookExit code
Python uvicorn, exec-form CMD200, waited forrefused2.5 s0
Python, shell-form CMD (sh -c is PID 1)200, kept serving200 (still serving)10.2 s, SIGKILL137
Python, shell form + --initcut offrefused0.33 s143
php -S (PID 1)200, kept serving200 (still serving)10.1 s, SIGKILL137
php -S + --initcut offrefused0.33 s143
php:8.3-apache (image stops with SIGWINCH)200, waited forclosed3.15 s0
php:8.3-apache, --stop-signal SIGTERMcut offrefused0.35 s0
Java HttpServer, no shutdown hookcut offclosed0.41 s143
Java HttpServer + hook server.stop(25)200, waited forclosed2.55 s143
Spring Boot 3.5 default200, waited forclosed2.6 s143
Spring Boot, server.shutdown=immediatecut offrefused0.37 s143
.NET 8 Kestrel200, waited forclosed2.55 s0

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.

WhereSetting for a 30 s grace period
docker rundocker run --stop-timeout 30 ...
docker stopdocker stop -t 30 app
Docker Composestop_grace_period: 30s
KubernetesterminationGracePeriodSeconds: 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.

RuntimeFailed, stop right after reloadFailed, wait 1 s then stopLongest gap, wait 1 s
Python 3.12 uvicorn0, 3, 00, 0, 053 ms
PHP 8.3 Apache3, 7, 80, 0, 053 ms
PHP 8.3 php -S (16 workers)0, 0, 0 (10 s stops)0, 0, 052 ms
PHP 8.3 php -S + --init18, 18, 180, 0, 053 ms
Java HttpServer, no hook95, 19, 160, 0, 153 ms
Java HttpServer + hook5, 7, 50, 0, 052 ms
Spring Boot 3.50, 7, 50, 0, 052 ms
Spring Boot, immediate12, 14, 150, 0, 054 ms
.NET 8 Kestrel4, 7, 60, 0, 052 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.

bash
# once: network, first app, nginx in front
docker network create apps
docker run -d --name app-blue --network apps --restart unless-stopped --stop-timeout 30 myapp:v1
mkdir -p nginx && cat > nginx/default.conf <<'CONF'
server {
  listen 80;
  location / {
    proxy_pass http://app-blue:8080;
    proxy_read_timeout 60s;
  }
}
CONF
docker run -d --name web --network apps -p 80:80 -v "$PWD/nginx:/etc/nginx/conf.d" nginx:1.29-alpine
bash
#!/bin/sh
# Start-then-switch redeploy on one server: nginx container "web" proxies to app-blue or app-green on network "apps".
# Usage: ./swap.sh myapp:v2     (conf dir mounted into nginx at /etc/nginx/conf.d)
set -eu
IMAGE=$1; CONF=${CONF:-./nginx/default.conf}; DRAIN=${DRAIN:-2}
OLD=$(grep -o 'app-[a-z]*' "$CONF" | head -1)
if [ "$OLD" = app-blue ]; then NEW=app-green; else NEW=app-blue; fi

docker rm -f "$NEW" 2>/dev/null || true
docker run -d --name "$NEW" --network apps --restart unless-stopped --stop-timeout 30 "$IMAGE"

# wait until the new container answers (from inside the nginx container, on the private network)
i=0
until docker exec web wget -q -O /dev/null "http://$NEW:8080/" 2>/dev/null; do
  i=$((i+1)); [ $i -gt 120 ] && { echo "$NEW never answered"; docker logs --tail 50 "$NEW"; docker rm -f "$NEW"; exit 1; }
  sleep 0.5
done

sed -i.bak "s/$OLD/$NEW/" "$CONF"
docker exec web nginx -t && docker exec web nginx -s reload
sleep "$DRAIN"            # reload returns before the new nginx workers take over; let the old ones finish
docker stop "$OLD"        # SIGTERM; the app drains its in-flight requests (up to --stop-timeout)
echo "live: $NEW ($IMAGE)"

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

RuntimeDrains on SIGTERM by default?What to set
Python (uvicorn)Yes, if it gets the signalExec-form CMD. Optional --timeout-graceful-shutdown 25 to cap the wait. With gunicorn, --graceful-timeout (30 s default)
PHP php -SNo: ignores SIGTERM as PID 1docker run --init (or tini as the entrypoint); better, php-fpm or Apache for production
PHP Apache (official image)Yes, via STOPSIGNAL SIGWINCHKeep the image's stop signal; raise the stop timeout above your slowest request
Java, plain JDK serverNoRuntime.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 sHostOptions.ShutdownTimeout under the stop timeout (e.g. builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25)))
Any, in Docker10 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