[Blog](https://console.f1b10.testing.getox.in/blog) [Engineering](https://console.f1b10.testing.getox.in/blog#c-engineering) # Zero-downtime deploys without Docker: systemd and Caddy Zero-downtime deployment on a VPS without Docker: blue-green with two systemd units, a health check and a Caddy upstream file. A copyable script inside. Saurav Sharma Oct 8, 2026 8 min read On this page - [Why a restart drops requests](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-why) - [Three ways to deploy without downtime](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-options) - [Blue-green by hand with systemd and Caddy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-by-hand) - [How ox does it on every deploy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-ox) - [What this does not cover](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-limits) - [Key takeaways](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#key-takeaways) - [Frequently asked questions](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#faq) - [Next step](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-next) You can get zero-downtime deployment on a plain VPS without Docker: run two copies of your app as systemd services on two ports, start the new release on the idle one, wait for its health check, then point Caddy at it and stop the old one. A bad release never gets traffic, and no request is dropped during the switch. This is blue-green deployment on one server. The simplest deploy on a VPS is `git pull` and `systemctl restart myapp`. It works, but for a few seconds nothing is listening: visitors get errors, and if the new code fails to start, the site stays down until you notice. You don't need Docker or Kubernetes to fix this. This post shows the pattern by hand with systemd and Caddy, with a deploy script you can copy, then explains exactly how ox does it on every deploy, including what it does not cover. **In this guide:** - [Why a restart drops requests](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-why) - [Three ways to deploy without downtime, compared](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-options) - [Blue-green by hand with systemd and Caddy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-by-hand) - [How ox does it on every deploy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-ox) - [What this does not cover](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-limits) - [Frequently asked questions](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#faq) ## Why a restart drops requests A restart stops the old process before the new one is ready. Between the two, the port is closed and the proxy in front answers with an error. A Django or Next.js app can take several seconds to start, longer if it loads a lot at import time. And if the new release crashes on start, there is no old process left to fall back on. ## Three ways to deploy without downtime - **Socket activation.** systemd holds the listening socket during the restart, so connections wait instead of failing. A slow start still makes them wait, and a broken release still takes the site down. - **Your app server's own graceful reload**, like gunicorn's `HUP` signal or Puma's phased restart. Useful, but specific to each server and language, and a bad release can still replace a good one. - **Two sides, then a switch**, often called blue-green. The new release starts next to the old one on another port. Only when it answers a health check does the proxy send traffic to it. Then the old one stops. A bad release never gets traffic. This works for any language. | Approach | Requests during a slow start | A release that fails to start | Works for any language | | --- | --- | --- | --- | | systemd socket activation | Wait until the app is up | Site down | Only if the app accepts a passed socket | | App server graceful reload | Served by old workers | Depends on the server | No, each server has its own | | Blue-green with a proxy switch | Served by the old side | Old side keeps serving | Yes | ## Blue-green by hand with systemd and Caddy ### 1. One folder per release Build each release in its own folder, never over the running one: Code ``` /srv/myapp/releases/2026-10-08-a1b2c3/ /srv/myapp/releases/2026-10-09-d4e5f6/ /srv/myapp/side-9001 -> releases/2026-10-08-a1b2c3 /srv/myapp/side-9002 -> releases/2026-10-09-d4e5f6 ``` Each side is a link to the release it runs, changed only while that side is stopped. ### 2. A systemd template unit, one instance per port INI ```ini # /etc/systemd/system/myapp@.service [Unit] Description=myapp on port %i [Service] User=myapp WorkingDirectory=/srv/myapp/side-%i EnvironmentFile=/etc/myapp.env Environment=PORT=%i ExecStart=/srv/myapp/side-%i/.venv/bin/gunicorn config.wsgi --bind 127.0.0.1:%i Restart=always ``` `myapp@9001` and `myapp@9002` are now two independent services from one file. The `%i` is the instance name, here the port; see systemd's [unit file documentation](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html) for templates and specifiers. ### 3. Make Caddy read the port from a file Caddy 2 has a [`{file.*}` placeholder](https://caddyserver.com/docs/conventions#placeholders) that reads a file's contents when a request needs it. Use it as the upstream address, and switching becomes a file write with no reload at all. In Caddy's JSON config, the route's handler is: Code ``` { "handler": "reverse_proxy", "upstreams": [{ "dial": "{file./etc/myapp/upstream}" }] } ``` and `/etc/myapp/upstream` holds one line, like `127.0.0.1:9001`. Why not edit the config and run `caddy reload`? A reload is careful, but it restarts Caddy's servers. On real servers under load, ox's own tests saw that restart reset connections still waiting in the accept queue. A file read per request has no such moment. ### 4. The deploy script Shell ```bash set -e NEW=9002; OLD=9001 # whichever side is idle becomes NEW ln -sfn releases/2026-10-09-d4e5f6 /srv/myapp/side-$NEW systemctl start myapp@$NEW # wait until the new side answers, or give up and leave the old side live for i in $(seq 1 60); do curl -fsS http://127.0.0.1:$NEW/healthz >/dev/null && break [ "$i" = 60 ] && { systemctl stop myapp@$NEW; exit 1; } sleep 2 done # switch: write a temp file, then rename it over the old one echo 127.0.0.1:$NEW > /etc/myapp/upstream.tmp mv /etc/myapp/upstream.tmp /etc/myapp/upstream sleep 10 # let requests on the old side finish systemctl stop myapp@$OLD ``` The rename matters. `mv` within one filesystem replaces the file in one step, so Caddy reads either the old port or the new one, never half a line. ### 5. Database migrations Run migrations after the build and before the switch. For a moment the old release runs against the new schema, so make each migration safe for both: add a column before the code uses it, and drop it in a later release. This is the one part no tool can do for you. ## How ox does it on every deploy ox runs this pattern for every app, with no setting to turn on. Each app gets two ports from ox's range, one per side. A deploy, in order: 1. Fetches the commit into a new release folder, installs dependencies and builds, without touching what is live. 2. Takes a snapshot of each PostgreSQL database that has tables, then runs your migration. 3. Restarts the workers your app calls over HTTP and waits for their health, then starts the app on the idle side. 4. Waits up to 120 seconds for the new side's health check: a 2xx or 3xx answer on your `[app] health` path when you set one, otherwise any HTTP answer to `GET /`. 5. Switches: it writes the new port to the upstream file Caddy reads on every request, and the static files' release id to a second file, one right after the other. Caddy does not reload. 6. Stops the old side 10 seconds later, then restarts the other workers, and keeps the newest 3 releases for rollback. If anything fails before the switch, the live release keeps serving and the run's log names the step, the cause, and a fix. If the switch itself fails, ox moves traffic back. Rollback uses the same switch with a kept release, so it needs no rebuild ([how a rollback works](https://console.f1b10.testing.getox.in/docs/operate/rollback)). The only time ox reloads Caddy is when the routes themselves change, like a new domain. For those reloads ox sets the kernel's `tcp_migrate_req`, which hands queued connections to the new listener instead of resetting them. Stock Caddy still lost a request that arrived at the very moment the old server shut down, about 7 in 15,000 under constant load in our lab, so ox runs its own build of the same Caddy version: before an old server shuts down, it stops accepting and answers every connection it already accepted. HTTP/2 and HTTP/3 clients get the protocol's own signal to retry on a new connection. Switching to a new release of your own app, which is the common case, does not reload Caddy at all. You get this by adding a server and a repository; there is nothing to configure. The [push-to-deploy features](https://console.f1b10.testing.getox.in/features#deploy) show the rest of a deploy, and the [`[app]` section of the config reference](https://console.f1b10.testing.getox.in/docs/config#app) covers the health path. ## What this does not cover - **Background workers restart in place.** Workers that don't serve HTTP, like a Celery worker, are restarted after the switch. Their queue keeps the jobs, but a job that was running may need to be retried. - **Migrations are not undone.** A rollback doesn't revert the schema. ox says so before you confirm, and the snapshot taken before the migration is there to restore. - **Long connections close.** A websocket open to the old side is closed when that side stops, so clients need to reconnect. - **Both sides use memory for a moment.** The server needs room for two copies of the app during the switch. - **Static files can race.** A request in the same instant as the switch may ask for a file only the old release has. File names with a content hash, which most build tools make, avoid it. ## Key takeaways - A plain `systemctl restart` drops requests and leaves the site down if the new release fails to start. - Blue-green on one VPS needs only two systemd instances, a health check and a proxy switch. No Docker, no Kubernetes. - Caddy's `{file.*}` placeholder makes the switch a file rename, with no config reload. - Migrations must work with both the old and the new code, because both run for a moment. - ox runs this on every deploy and rollback with nothing to configure. ## Frequently asked questions ### Is this blue-green deployment? Yes, on one server. The two sides, each a systemd instance on its own port, are the blue and green copies, and the upstream file Caddy reads is the switch. Classic blue-green uses two servers behind a load balancer; on a single VPS the idea is the same, only cheaper, as long as the server has memory for both copies. ### Do I need Docker or Kubernetes for zero-downtime deploys? No. Two systemd services and a proxy that can switch between them are enough, on any VPS. Containers are one way to run two copies side by side, but a release folder per version and a systemd template unit do the same job with less on the server, and the pattern works for any language. ### Can I do this with nginx instead of Caddy? Yes. nginx's reload starts new workers and lets the old ones finish their requests, so the same two-sides pattern works with an upstream you change and reload. ox uses Caddy because it also handles HTTPS certificates on its own, and the per-request file read means the switch needs no reload at all. ### What should the health check do? Answer quickly with a success status once the app can serve real requests: ox accepts any 2xx or 3xx on the path you set. Checking that the database connection works is useful. Calling slow outside services is not, because a payment provider having a bad minute should not block your deploy. ### Does this work for Django, Next.js and FastAPI? Yes. The switch happens in front of the app, so any language works the same way, as long as the app reads its port from `$PORT`. See [how to deploy Django to a VPS without Docker](https://console.f1b10.testing.getox.in/blog/deploy-django-to-a-vps-without-docker) for a full example, and the [Heroku alternative guide](https://console.f1b10.testing.getox.in/blog/heroku-alternative-on-your-own-server) for moving an existing app. ## Next step Try the script above on a staging server first, with a deliberately broken commit, and watch the old side keep serving. Or let ox run the same switch for you: sign up with GitHub, add a server, and push. New to running apps on your own server? [How ox self-hosts your apps](https://console.f1b10.testing.getox.in/self-hosting) shows what ox sets up on your server. On this page - [Why a restart drops requests](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-why) - [Three ways to deploy without downtime](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-options) - [Blue-green by hand with systemd and Caddy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-by-hand) - [1. One folder per release](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-releases) - [2. A systemd template unit, one instance per port](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-unit) - [3. Make Caddy read the port from a file](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-caddy) - [4. The deploy script](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-deploy) - [5. Database migrations](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-migrations) - [How ox does it on every deploy](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-ox) - [What this does not cover](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-limits) - [Key takeaways](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#key-takeaways) - [Frequently asked questions](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#faq) - [Is this blue-green deployment?](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-faq-bluegreen) - [Do I need Docker or Kubernetes for zero-downtime deploys?](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-faq-docker) - [Can I do this with nginx instead of Caddy?](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-faq-nginx) - [What should the health check do?](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-faq-health) - [Does this work for Django, Next.js and FastAPI?](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-faq-framework) - [Next step](https://console.f1b10.testing.getox.in/blog/zero-downtime-deploys-systemd-caddy#heading-next) Written by Saurav Sharma Builds ox. Writes about running apps on servers you own, without Docker and without a platform bill. [Older post Agentic Loops and Swarms Are the New Moat for Dev Tools](https://console.f1b10.testing.getox.in/blog/agentic-loops-swarm-is-the-new-moat) [Newer post A Heroku alternative on your own VPS, without Docker](https://console.f1b10.testing.getox.in/blog/heroku-alternative-on-your-own-server) ## Keep reading - Engineering [Case Study: Raising a Vue App's Lighthouse Score to 93](https://console.f1b10.testing.getox.in/blog/case-study-how-i-improved-frontend-page-speed) A Vue and Vite case study: how route splitting, lazy components, deferred analytics and leaner fonts raised a Lighthouse performance score from 60 to 93. Saurav Sharma Mar 10, 2026 6 min read - Guides [How to Self-Host Web Apps on Your Own VPS (2026 Guide)](https://console.f1b10.testing.getox.in/blog/self-host-web-apps-on-your-own-vps) What you need to self-host a web app on a VPS: server, domain, HTTPS, database, backups. Four ways to set it up, their trade-offs, and how ox does it. Saurav Sharma Oct 15, 2026 8 min read - Product [A Coolify Alternative Without Docker, on Your Own VPS](https://console.f1b10.testing.getox.in/blog/coolify-alternative-without-docker) Want Coolify's dashboard without Docker? How ox runs apps as plain systemd services on your VPS, a fair comparison, and when Coolify is the better pick. Saurav Sharma Oct 15, 2026 7 min read ## Deploy to a server you own ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free for your first project. [Sign up with GitHub](https://console.f1b10.testing.getox.in/login/github) [Read the docs](https://console.f1b10.testing.getox.in/docs)