Draft, not yet published
Zero-downtime deploys, explained for people who've only ever redeployed by restarting
7 July 2026· 5 min read · by Stackbastion
Right now, deploying means you stop the running app and start the new version. For a few seconds, nothing’s up, and anyone hitting the site gets an error or a spinner. It works, but it means every release is a tiny outage, and you’ve learned to deploy at 2am to avoid being seen. There’s a standard way to ship updates with no gap at all, and it’s simpler than it sounds.
Why the stop-then-start approach causes downtime
When you run one copy of your app and restart it to deploy, there’s a window where the old version is already gone and the new one isn’t ready yet. The new process has to boot, load its code, connect to the database, and warm up. During those seconds, requests arrive and there’s nothing to answer them. Users see a 502 Bad Gateway or a dead page.
It feels unavoidable because you’re thinking of the app as a single thing that’s either on or off. The trick to zero downtime is to never have zero copies running. You bring up the new version alongside the old one, make sure it’s actually healthy, shift traffic over, and only then retire the old version. At no point is there a gap.
Two ideas make this work: running more than one copy, and a health check that proves a copy is ready before it gets traffic.
How to deploy with no downtime
Step 1: run at least two copies behind a proxy.
Instead of one app process, run two (or more) identical copies. A reverse proxy (Caddy, Nginx, or your platform’s load balancer) sits in front and spreads requests across them. Now you can take one copy down to update it while the other keeps serving. Users never notice, because there’s always a live copy.
Step 2: add a health check endpoint the proxy can trust.
The proxy needs a way to know whether a copy is actually ready, not just “the process started.” Add a tiny endpoint that returns 200 only when the app can really do its job, including talking to the database:
// GET /healthz : returns 200 only when truly ready to serve
app.get("/healthz", async (_req, res) => {
try {
await db.query("select 1"); // can we reach the database?
res.status(200).json({ status: "ok" });
} catch {
res.status(503).json({ status: "not ready" });
}
});
This matters because a booting process can accept connections before it’s ready to serve them. The health check is the difference between “the app is up” and “the app works.” Only route real traffic to a copy once /healthz returns 200.
Step 3: roll the deploy one copy at a time.
The rolling pattern: update copy A while copy B serves everyone, wait for A to pass its health check, shift traffic to A, then update B the same way. With Docker Compose and a proxy, the config expresses “check health before considering it live”:
services:
app:
image: myapp:latest
deploy:
replicas: 2
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s # grace time while the app boots
The healthcheck block tells the platform not to treat a container as ready until /healthz passes. During a rolling update, traffic only moves to a new container after it’s healthy, so there’s never a moment where requests hit a not-ready process.
For a manual setup behind Caddy, the same idea is a health check on the upstream so the proxy skips any backend that isn’t answering:
your-app.com {
reverse_proxy app_a:3000 app_b:3000 {
health_uri /healthz
health_interval 10s
health_timeout 3s
}
}
Caddy load-balances across both backends and stops sending traffic to either one the instant its health check fails, which is exactly what you want while one is restarting for a deploy.
Step 4: drain connections instead of killing them.
The last detail is the old copy. When you retire it, don’t kill it mid-request. Let it finish the requests it’s already handling (“draining”) while the proxy stops sending it new ones. Most proxies and orchestrators do this if you give the process a moment to shut down gracefully, so make sure your app handles a shutdown signal by finishing in-flight work before exiting rather than dropping everything.
Put together: two copies, a real health check, roll one at a time, drain on the way out. That’s zero-downtime deployment. No 2am releases, no error window, deploy in the middle of the day and nobody notices.
Or, we do it for you
Managed hosting sets up rolling deploys and health checks for you, so every release ships with no downtime and no midnight timing games. If you want a second pair of eyes on your setup regardless of what you built it with, get a free production audit.
FAQ
Do I need Kubernetes for zero-downtime deploys?
No. Kubernetes does this well, but it’s overkill for most apps. Two container replicas behind a reverse proxy with a health check gives you the same zero-downtime result on a single server, using Docker Compose and Caddy or Nginx. Reach for Kubernetes when you have the scale and team to justify it, not before.
What’s the difference between a health check and just pinging the homepage?
A homepage ping tells you the web server answers. A real health check confirms the app can do its job, which usually means it can reach the database and any other dependency it needs. A process can serve a static page while being unable to run a real request, so route traffic based on the deeper check, not a surface ping.
Why do I need two copies of the app running?
Because zero downtime means never having zero copies available. If you run one copy and restart it to deploy, there’s a gap where nothing serves requests. A second copy keeps serving during that gap. It also gives you a little failover: if one copy crashes, the other still answers while it restarts.
What does “draining connections” mean?
It’s letting the old version finish the requests it’s already handling before it shuts down, instead of killing it instantly and dropping those requests. During a deploy, the proxy stops sending new traffic to the old copy, waits for its in-flight requests to complete, then retires it. That’s why users mid-action during a deploy don’t see a broken response.