Zero-Downtime Deploys for Docker Apps on a Single VPS

How to swap Docker containers on one server without dropping requests: health checks, blue/green switching, graceful shutdown and safe schema changes.

Restarting a container with docker compose up -d is fast, but it is not zero downtime. For a few seconds the old container is gone and the new one is not ready, and every request in that window gets a 502. On a single VPS with no load balancer in front, you can still remove that gap. Here is the approach that works for most small web apps.

Why the gap happens

When Compose recreates a service, it stops the old container first, then starts the new one. The reverse proxy keeps sending traffic to an address that no longer answers. Even after the new container starts, the app may need a few seconds to load config, warm caches or open database connections.

So there are two problems to solve:

  1. Something has to keep serving while the new version starts.
  2. Traffic should only move once the new version is actually ready.

Step 1: Give the app a real health endpoint

A container being “up” proves nothing. Add an endpoint such as /healthz that returns 200 only when the app can serve requests, including a quick database check if the app depends on one. Then tell Docker about it:

services:
  web:
    image: registry.example.com/web:${TAG}
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s

Use whatever HTTP client actually exists in your image. Slim images often lack curl.

Step 2: Run two copies, briefly

The simplest pattern on one server is a blue/green swap:

  1. Start the new version alongside the old one under a different name, for example web_green next to web_blue.
  2. Wait until Docker reports the new container as healthy.
  3. Point the reverse proxy at the new container and reload it gracefully.
  4. Wait for in-flight requests to finish, then stop the old container.

A small script handles this well:

NEW=green; OLD=blue
docker compose -p app_$NEW up -d web
until [ "$(docker inspect -f '{{.State.Health.Status}}' app_${NEW}-web-1)" = "healthy" ]; do sleep 2; done
# switch upstream, then reload the proxy without dropping connections
sed -i "s/app_${OLD}-web-1/app_${NEW}-web-1/" /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
sleep 15
docker compose -p app_$OLD down

Adapt the names to your setup. The important parts are the health wait and a proxy that can reload without dropping connections. Caddy, Nginx (nginx -s reload) and Traefik all support that.

Step 3: Make the app shut down politely

When Docker stops the old container it sends SIGTERM, waits (10 seconds by default), then sends SIGKILL. Your app should catch SIGTERM, stop accepting new connections, finish current requests and then exit. In Node that means calling server.close() on SIGTERM. Raise stop_grace_period in Compose if some requests run long.

Also check that your process actually receives the signal. If the container starts through a shell script without exec, the shell gets SIGTERM and the app never hears it.

Step 4: Handle the database carefully

Code swaps are easy. Schema changes are where zero downtime usually breaks, because for a short time old and new code run against the same database.

The safe rule is expand, then contract:

  • Expand: add new columns or tables in a way the old code ignores. Make new columns nullable or give them defaults.
  • Deploy the new code that uses them.
  • Contract: in a later deploy, remove the old columns once nothing reads them.

Renaming a column in one step is the classic way to break the old version during the swap.

Step 5: Have a fast way back

Tag images with the Git commit, not latest. If the new version misbehaves after the switch, rolling back is the same swap in reverse, pointing at the previous tag. Keep the last few images on the server so a rollback does not need a registry pull.

What this costs you

You need enough memory to run two copies of the app for a minute or so. On a small VPS, check that with docker stats before relying on it. If memory is tight, build images in CI rather than on the server, so the build does not compete with the swap.

Doing it without scripts

If you would rather not maintain the swap script yourself, several self-hosted deployment tools handle health-checked rollouts for you. There is a longer comparison of approaches, including rolling updates and proxy-based switching, in this guide to zero-downtime deployments with Docker. For the wider setup around it, such as restart policies, resource limits and secrets, see Docker Compose in production.

The core idea stays the same whichever tool you use: start the new version, prove it is healthy, move traffic, then retire the old one.


Write a comment