Zero downtime deployments with Docker, Caddy Server, and Bash
TLDR
- Pull your new docker image.
- Docker run “your-app-new” on a new port.
- Load balance your reverse proxy to 2 servers simultaneously: “your-app” and “your-app-new”.
- Send a graceful shutdown signal to “your-app”.
- Spin up the new docker image as “your-app”.
- Load your original Caddy config to only load balance to “your-app”.
- Shut down “your-app-new”.
I deploy this website to a single VPS. It's a Go web server packaged in Docker with html stored in a SQLite database. Deployments only take a few seconds, and realistically, nobody would notice the brief downtime except me.
So why bother with zero-downtime deployments?
Mostly because I wanted to learn how they work.
A lot of deployment guides jump straight to Kubernetes, service meshes, or blue-green deployments spread across multiple servers. That's great if you're running infrastructure at scale, but I wanted something much simpler. One server, one application, a Bash script, and the tools I was already using.
The Naïve Deployment
My original deployment script looked something like this:
1 2 3 4 5 6 7 8 | |
There's one obvious problem with this approach.
Between stopping the old container and starting the new one, there's a window where nothing is listening for requests. If someone visits the site at just the wrong moment, they'll get an error.
The outage might only last a second or two, but it's still downtime.
Why Not Kubernetes?
Because it would be complete overkill.
I have one server and one application. I wasn't trying to solve distributed systems problems—I was trying to understand the mechanics behind a seamless deployment.
I also like Bash.
A shell script is portable, easy to read, and doesn't require another service to keep running. If I can automate something with a few dozen lines of Bash, that's usually my first choice.
The Key Insight
Eventually I realized I didn't need to replace the running application immediately.
I could simply run the new version alongside the old one.
Instead of replacing this:
Caddy
│
▼
turriate
I temporarily run two copies:
Caddy
/ \
▼ ▼
turriate turriate-new
Once both servers are running, Caddy can load balance requests between them. Existing requests continue to finish on the old container while new requests begin reaching the new one.
Once the old process exits, I can promote the new container and remove the temporary one.
The Deployment
The deployment now looks like this:
- Pull the latest Docker image.
- Start a second container (
turriate-new) on a different port. - Wait for the new application to become healthy.
- Reload Caddy so it load balances between both containers.
- Gracefully shut down the old container.
- Start the new image as
turriate. - Reload Caddy to point back to the primary container.
- Remove the temporary container.
During the deployment, the Caddy configuration temporarily changes from a single backend:
1
| |
to two:
1
| |
After the deployment finishes, it switches back to a single backend.
One of my favorite things about Caddy is how easy this is. I simply update the Caddyfile and run caddy reload. The configuration changes happen without restarting the server or interrupting active connections.
Graceful Shutdown
The last piece of the puzzle is shutting down the old server gracefully.
Rather than killing it immediately, the application stops accepting new connections while allowing any in-flight requests to finish. Because Caddy is already sending traffic to both containers, new requests naturally begin flowing to the new instance as the old one drains.
This avoids disconnecting users in the middle of a request.
Is This Production Ready?
Probably not for every application.
If you're deploying multiple services, multiple servers, or performing database migrations that aren't backward compatible, you'll quickly outgrow this approach.
But for a single VPS running a stateless web application, it's surprisingly effective.
More importantly, building it taught me how zero-downtime deployments actually work under the hood. Once you understand the basic idea—running the old and new versions side by side while traffic shifts between them—the larger deployment systems become much easier to understand.
Sometimes the best way to learn isn't by installing Kubernetes.
Sometimes it's just Docker, Caddy, and a Bash script.