For a long time my deploy process on small client servers was one command and a prayer. I would SSH in, run docker compose up -d --build, watch the logs scroll, and tell myself that a few seconds of broken pages was the price of shipping. Then a client opened their own site mid-deploy, got a bare 502 from the proxy, and sent me a screenshot with no text at all. The silence was worse than an angry message.
Zero downtime deployment sounds like something you need Kubernetes for. You don’t. On a single VPS you can get to the point where a deploy is invisible to visitors using two things you probably already run: Docker Compose and a reverse proxy that does health checks. I use Caddy for this, and the setup below is what I now put on every new box.
I should say up front what this is not. It isn’t a database migration strategy, and it won’t save you from a deploy that ships a broken build with a healthy-looking /health route. It handles the boring, common case: replacing the app container without dropping requests.
Why a plain compose up drops requests
When you change an image and run docker compose up -d, Compose stops the old container and starts a new one in its place. There is a gap. The old process gets SIGTERM, the new process boots, loads its config, warms its connections, and only then starts listening. Anything that arrives between those two moments hits a closed port.
The proxy in front of it makes this visible. Caddy tries to connect to web:3000, gets refused, and returns a 502. Nothing is wrong with Caddy. It did exactly what it was told.
The fix has to cover two separate problems. First, the old container must stay alive until the new one is ready. Second, the proxy must know which containers are ready, and send traffic only to those. Most “zero downtime” tutorials solve the first and forget the second, which is how you end up with two containers running and requests still landing on the one that is halfway through booting.
Start with a health check that means something
Compose can wait for a container to be healthy before it considers the deploy finished. The Compose service reference documents healthcheck with interval, timeout, retries, start_period and start_interval. That last one only exists from Compose 2.20.2, so check your version on old servers before you rely on it.
Here is the service I start from:
services:
web:
image: registry.example.com/myapp:${TAG}
restart: unless-stopped
stop_grace_period: 30s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 5s
timeout: 3s
retries: 3
start_period: 20s
networks: [internal]
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
networks: [internal]
networks:
internal:
volumes:
caddy_data:
Two details matter here. The web service publishes no host port, so two copies of it can run side by side without fighting over a port. And stop_grace_period gives the old container time to finish in-flight requests after it receives SIGTERM, instead of Docker’s default ten seconds.
I wrote a longer rant about what depends_on actually waits for in my post on Compose health checks. The short version: a container that has started is not a container that can serve a request, and your /health route needs to check something real. A route that returns 200 without touching the database is a smoke detector with the battery taken out.
Let Caddy find both copies
Compose gives every service a DNS name on the network. If web has two running containers, a lookup for web returns two addresses. Caddy’s reverse_proxy can use that directly with the dynamic a upstream source, which the reverse_proxy docs describe as querying a domain name and refreshing the cached results on an interval (the default is one minute, which is far too slow for us).
example.com {
reverse_proxy {
dynamic a web 3000 {
refresh 2s
}
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
health_uri /health
health_interval 2s
health_fails 1
health_passes 2
}
}
The pieces do different jobs. refresh 2s makes Caddy notice new containers quickly. The active health check (health_uri plus health_interval) marks an upstream as down when /health fails, so a container that is still booting gets no traffic. And lb_try_duration tells Caddy to keep trying to pick a healthy backend for up to five seconds instead of failing immediately. The docs are clear that retries are off by default, so this line is doing real work. A visitor who arrives in the middle of the swap waits a moment rather than seeing an error page.
I’m not fully sure refresh 2s is the right number for every setup. Two seconds worked on the small boxes I run. On a busy server with many services, more DNS lookups might matter, and I haven’t measured that.
The deploy script
With that in place, the deploy is three steps: start a second copy, wait until it is healthy, then remove the old one.
#!/usr/bin/env bash
set -euo pipefail
export TAG="$1"
docker compose pull web
# Remember which containers are running the old version
OLD=$(docker compose ps -q web)
# Add a second copy built from the new tag, keep the old one untouched
docker compose up -d --no-deps --no-recreate --scale web=2 --wait --wait-timeout 90 web
# New copy is healthy, so retire the old ones
docker stop --time 30 $OLD
docker rm $OLD
# Let Compose settle back to a single replica
docker compose up -d --no-deps --no-recreate --scale web=1 web
The order of those lines is the whole trick, so here is why each one is there.
Recording OLD before anything else matters because Compose, not you, decides which container to remove when you scale down. If you scale to one and let it choose, there is no promise it picks the old one. Writing the IDs down first means you stop exactly the containers you meant to. I learned this by reading the output of docker ps after a test deploy and noticing the container with the newer image was the one that had disappeared. That was a staging box, which is the only reason it was funny.
The --no-recreate flag is what keeps the old container alive. The docker compose up reference says it stops Compose from recreating containers that already exist. Because TAG has changed, the extra replica that gets created is built from the new image, and the existing one keeps serving until we stop it.
The --wait flag is the gate. According to the same reference it waits for services to be running or healthy and implies detached mode, with --wait-timeout as the upper bound. If the new container never becomes healthy, the command fails, set -e ends the script, and the old container is still serving. A failed deploy that changes nothing is a good failure. You can also roll back by exporting the previous tag and running the script again, since the script has no memory of what came before.
Prove it with a loop
I don’t trust a zero downtime claim I haven’t watched. Before the first real deploy, run a dumb load loop in one terminal and the deploy in another:
while true; do
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" https://example.com/
sleep 0.2
done | tee deploy-check.log
Afterwards, grep -v '^200' deploy-check.log shows every request that didn’t succeed. Look at the time column too. During the overlap you may see a few requests that take a second or two instead of a few milliseconds. That is lb_try_duration quietly doing its job, and it is the difference between a slow page and a broken one. If you see 502s, check the three usual suspects in order: the health route returns 200 before the app can really serve, refresh is still at its default minute, or lb_try_duration is missing.
I also keep a second loop that hits an endpoint which needs the database, because a lot of apps answer a static route happily while their connection pool is still empty.
What this doesn’t cover
Be honest with yourself about three gaps.
Database migrations. If the new code needs a column the old code does not know about, both versions run at the same time for a short window. Write migrations that the old code can survive: add columns first, remove them in a later deploy.
Long-lived connections. WebSockets and server-sent events will be cut when the old container stops. Clients need to reconnect, and they should. If yours don’t, that is a bug you have anyway.
State on disk. If the app writes uploads to a local volume, two containers share that volume, which is fine for reads and a hazard for anything else.
If this all feels like a lot of ceremony for one app, there are tools that wrap it for you. I compared two of them after migrating a client, in the Coolify vs Dokploy write-up. I still prefer the raw script for boxes where I want to understand every moving part.
Try it this week
Pick one low-traffic service on a VPS you control. Add a real health route, remove its host port, put Caddy in front with dynamic a and lb_try_duration, and run the script above while hitting the site with curl in a loop from another terminal. Count the non-200 responses. If you get zero, you now have a deploy you can run at noon on a Tuesday. If you get some, the loop output tells you exactly which of the pieces is missing. I do this kind of infrastructure work for clients, and you can see more of it on my portfolio.