{"id":776,"date":"2026-10-12T13:00:22","date_gmt":"2026-10-12T13:00:22","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/zero-downtime-deployment-docker-compose-caddy-one-vps\/"},"modified":"2026-10-12T13:00:22","modified_gmt":"2026-10-12T13:00:22","slug":"zero-downtime-deployment-docker-compose-caddy-one-vps","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/zero-downtime-deployment-docker-compose-caddy-one-vps\/","title":{"rendered":"Zero Downtime Deployment on One VPS With Compose and Caddy"},"content":{"rendered":"<p>For a long time my deploy process on small client servers was one command and a prayer. I would SSH in, run <code>docker compose up -d --build<\/code>, 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.<\/p>\n<p>Zero downtime deployment sounds like something you need Kubernetes for. You don&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/blog\/caddy-vs-nginx-small-vps-two-line-config-and-where-nginx-still-wins\/\" rel=\"noopener\">Caddy<\/a> for this, and the setup below is what I now put on every new box.<\/p>\n<p>I should say up front what this is not. It isn&rsquo;t a database migration strategy, and it won&rsquo;t save you from a deploy that ships a broken build with a healthy-looking <code>\/health<\/code> route. It handles the boring, common case: replacing the app container without dropping requests.<\/p>\n<h2 id=\"why-a-plain-compose-up-drops-requests\">Why a plain compose up drops requests<\/h2>\n<p>When you change an image and run <code>docker compose up -d<\/code>, 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.<\/p>\n<p>The proxy in front of it makes this visible. Caddy tries to connect to <code>web:3000<\/code>, gets refused, and returns a 502. Nothing is wrong with Caddy. It did exactly what it was told.<\/p>\n<p>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 &ldquo;zero downtime&rdquo; 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.<\/p>\n<h2 id=\"start-with-a-health-check-that-means-something\">Start with a health check that means something<\/h2>\n<p>Compose can wait for a container to be healthy before it considers the deploy finished. The <a href=\"https:\/\/docs.docker.com\/reference\/compose-file\/services\/\" rel=\"nofollow noopener\" target=\"_blank\">Compose service reference<\/a> documents <code>healthcheck<\/code> with <code>interval<\/code>, <code>timeout<\/code>, <code>retries<\/code>, <code>start_period<\/code> and <code>start_interval<\/code>. That last one only exists from Compose 2.20.2, so check your version on old servers before you rely on it.<\/p>\n<p>Here is the service I start from:<\/p>\n<pre><code class=\"language-yaml\">services:\n  web:\n    image: registry.example.com\/myapp:${TAG}\n    restart: unless-stopped\n    stop_grace_period: 30s\n    healthcheck:\n      test: [&quot;CMD&quot;, &quot;wget&quot;, &quot;-qO-&quot;, &quot;http:\/\/localhost:3000\/health&quot;]\n      interval: 5s\n      timeout: 3s\n      retries: 3\n      start_period: 20s\n    networks: [internal]\n\n  caddy:\n    image: caddy:2\n    ports: [&quot;80:80&quot;, &quot;443:443&quot;]\n    volumes:\n      - .\/Caddyfile:\/etc\/caddy\/Caddyfile\n      - caddy_data:\/data\n    networks: [internal]\n\nnetworks:\n  internal:\n\nvolumes:\n  caddy_data:\n<\/code><\/pre>\n<p>Two details matter here. The <code>web<\/code> service publishes no host port, so two copies of it can run side by side without fighting over a port. And <code>stop_grace_period<\/code> gives the old container time to finish in-flight requests after it receives SIGTERM, instead of Docker&rsquo;s default ten seconds.<\/p>\n<p>I wrote a longer rant about what <code>depends_on<\/code> actually waits for in my <a href=\"https:\/\/abrarqasim.com\/blog\/docker-compose-healthcheck-the-depends-on-that-only-waited-for-start\/\" rel=\"noopener\">post on Compose health checks<\/a>. The short version: a container that has started is not a container that can serve a request, and your <code>\/health<\/code> route needs to check something real. A route that returns 200 without touching the database is a smoke detector with the battery taken out.<\/p>\n<h2 id=\"let-caddy-find-both-copies\">Let Caddy find both copies<\/h2>\n<p>Compose gives every service a DNS name on the network. If <code>web<\/code> has two running containers, a lookup for <code>web<\/code> returns two addresses. Caddy&rsquo;s <code>reverse_proxy<\/code> can use that directly with the <code>dynamic a<\/code> upstream source, which the <a href=\"https:\/\/caddyserver.com\/docs\/caddyfile\/directives\/reverse_proxy\" rel=\"nofollow noopener\" target=\"_blank\">reverse_proxy docs<\/a> 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).<\/p>\n<pre><code class=\"language-caddyfile\">example.com {\n    reverse_proxy {\n        dynamic a web 3000 {\n            refresh 2s\n        }\n        lb_policy round_robin\n        lb_try_duration 5s\n        lb_try_interval 250ms\n\n        health_uri \/health\n        health_interval 2s\n        health_fails 1\n        health_passes 2\n    }\n}\n<\/code><\/pre>\n<p>The pieces do different jobs. <code>refresh 2s<\/code> makes Caddy notice new containers quickly. The active health check (<code>health_uri<\/code> plus <code>health_interval<\/code>) marks an upstream as down when <code>\/health<\/code> fails, so a container that is still booting gets no traffic. And <code>lb_try_duration<\/code> 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.<\/p>\n<p>I&rsquo;m not fully sure <code>refresh 2s<\/code> 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&rsquo;t measured that.<\/p>\n<h2 id=\"the-deploy-script\">The deploy script<\/h2>\n<p>With that in place, the deploy is three steps: start a second copy, wait until it is healthy, then remove the old one.<\/p>\n<pre><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -euo pipefail\n\nexport TAG=&quot;$1&quot;\ndocker compose pull web\n\n# Remember which containers are running the old version\nOLD=$(docker compose ps -q web)\n\n# Add a second copy built from the new tag, keep the old one untouched\ndocker compose up -d --no-deps --no-recreate --scale web=2 --wait --wait-timeout 90 web\n\n# New copy is healthy, so retire the old ones\ndocker stop --time 30 $OLD\ndocker rm $OLD\n\n# Let Compose settle back to a single replica\ndocker compose up -d --no-deps --no-recreate --scale web=1 web\n<\/code><\/pre>\n<p>The order of those lines is the whole trick, so here is why each one is there.<\/p>\n<p>Recording <code>OLD<\/code> 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 <code>docker ps<\/code> 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.<\/p>\n<p>The <code>--no-recreate<\/code> flag is what keeps the old container alive. The <a href=\"https:\/\/docs.docker.com\/reference\/cli\/docker\/compose\/up\/\" rel=\"nofollow noopener\" target=\"_blank\">docker compose up reference<\/a> says it stops Compose from recreating containers that already exist. Because <code>TAG<\/code> has changed, the extra replica that gets created is built from the new image, and the existing one keeps serving until we stop it.<\/p>\n<p>The <code>--wait<\/code> flag is the gate. According to the same reference it waits for services to be running or healthy and implies detached mode, with <code>--wait-timeout<\/code> as the upper bound. If the new container never becomes healthy, the command fails, <code>set -e<\/code> 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.<\/p>\n<h2 id=\"prove-it-with-a-loop\">Prove it with a loop<\/h2>\n<p>I don&rsquo;t trust a zero downtime claim I haven&rsquo;t watched. Before the first real deploy, run a dumb load loop in one terminal and the deploy in another:<\/p>\n<pre><code class=\"language-bash\">while true; do\n  curl -s -o \/dev\/null -w &quot;%{http_code} %{time_total}\\n&quot; https:\/\/example.com\/\n  sleep 0.2\ndone | tee deploy-check.log\n<\/code><\/pre>\n<p>Afterwards, <code>grep -v '^200' deploy-check.log<\/code> shows every request that didn&rsquo;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 <code>lb_try_duration<\/code> 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, <code>refresh<\/code> is still at its default minute, or <code>lb_try_duration<\/code> is missing.<\/p>\n<p>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.<\/p>\n<h2 id=\"what-this-doesnt-cover\">What this doesn&rsquo;t cover<\/h2>\n<p>Be honest with yourself about three gaps.<\/p>\n<p>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.<\/p>\n<p>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&rsquo;t, that is a bug you have anyway.<\/p>\n<p>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.<\/p>\n<p>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 <a href=\"https:\/\/abrarqasim.com\/blog\/coolify-vs-dokploy-what-actually-differs-after-migrating-a-client\/\" rel=\"noopener\">Coolify vs Dokploy write-up<\/a>. I still prefer the raw script for boxes where I want to understand every moving part.<\/p>\n<h2 id=\"try-it-this-week\">Try it this week<\/h2>\n<p>Pick one low-traffic service on a VPS you control. Add a real health route, remove its host port, put Caddy in front with <code>dynamic a<\/code> and <code>lb_try_duration<\/code>, and run the script above while hitting the site with <code>curl<\/code> 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 <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How I deploy to a single VPS without dropping requests: Docker Compose health checks, a second replica, and Caddy dynamic upstreams. Script and test loop included.<\/p>\n","protected":false},"author":2,"featured_media":775,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"How I deploy to a single VPS without dropping requests: Docker Compose health checks, a second replica, and Caddy dynamic upstreams. Script and test loop included.","rank_math_focus_keyword":"zero downtime deployment","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[654,129,80,125,8],"class_list":["post-776","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-caddy","tag-deployment","tag-devops","tag-docker","tag-self-hosting"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/776","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=776"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/776\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/775"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=776"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=776"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=776"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}