← Back to Writing
DevOps Docker CI/CD

Zero-Downtime Deploys Without Kubernetes: Rolling Updates on Docker Swarm

July 13, 2025 · 6 min read

For a long time my deploys were: SSH in, run docker compose down, run docker compose up, and eat a few seconds of downtime while connections dropped and in-flight requests died. Fine for a side project. Not fine once there are users mid-checkout and money moving. But the jump everyone reaches for, full Kubernetes for six services, buys a mountain of operational complexity that you then have to run, patch, secure, and debug at 2am. For a small fleet, there is a middle path that gets you zero-downtime deploys without the cluster, and it is mostly Docker Swarm doing one job well.

flowchart LR CI["CI build"]:::ghost --> R["Registry (GHCR)"] R --> SW["Swarm service update<br/>rolling · healthcheck · auto-rollback"] SW --> CA["Caddy<br/>routes to healthy tasks only"] CA --> U["Users, no dropped requests"]:::accent

Docker Swarm rolling updates

Swarm runs your service as N replicas and updates them a controlled few at a time. The settings that matter are few but load-bearing. update-parallelism sets how many replicas to replace at once, which for a small fleet is one. update-delay adds a pause between batches so a bad rollout reveals itself before it has spread to every replica. A container healthcheck tells Swarm to route traffic to a new task only once it reports healthy. And update-failure-action: rollback means a broken image reverts itself instead of taking the service down while you scramble. Tuned together, an updating service always has healthy replicas serving traffic.

flowchart TD N["New image tag"]:::ghost --> R1["Update replica 1"] R1 --> HC1{"Healthy?"} HC1 -->|yes| R2["Update replica 2"] HC1 -->|no| RB["Auto-rollback to old image"]:::gold R2 --> HC2{"Healthy?"} HC2 -->|yes| DONE["All replicas updated"]:::accent HC2 -->|no| RB

The healthcheck is the linchpin

A shallow healthcheck defeats the entire scheme, quietly. "The process is up" is not health; a process can be running and completely unable to serve a request. Real health is "I can reach the database, the cache, and the dependencies I need, and I am ready to take traffic." If your healthcheck only proves the process started, Swarm will confidently shift traffic onto replicas that return errors on every request, and your zero-downtime deploy becomes a fast, automated outage. Spend the time to make the check mean something, and keep it cheap enough to run every few seconds.

A health-aware proxy in front

Caddy sits in front, terminates TLS and renews certificates automatically, and routes only to healthy tasks. That is what makes the brief window where old and new versions coexist invisible to users: old connections drain to completion on the old replicas while new connections land on ready new ones. Connection draining matters more than it sounds, because killing a replica with requests still in flight is its own small outage, one request at a time, for whoever was unlucky.

The discipline it demands

Here is the catch that bites people, and it has nothing to do with Swarm. During a rolling update, two versions of your app run at the same time, briefly. Every change therefore has to be backward-compatible across a single deploy, which is mostly a database-migration discipline. You use expand and contract: first deploy adds the new column and writes to both old and new, then you backfill historical rows, then a later deploy stops reading the old column, and only then, in a third deploy, do you drop it. Skip that and a "zero-downtime" deploy still breaks, just more confusingly, because version one and version two of your code disagree about the schema while both are live. The deploy mechanism is easy; the data discipline is the actual work.

Stateful services are not cattle

Rolling updates work beautifully for stateless services, where any replica is interchangeable and you can replace one mid-flight without consequence. Databases, queues, and anything holding state are a different animal, and treating them like cattle is how you turn a routine deploy into a data incident. You do not roll a database the way you roll a web tier; you treat schema changes as their own carefully sequenced migrations, you let the data layer manage its own replication and failover rather than having the orchestrator yank a primary out from under live connections, and you are deliberate about the order of operations so the stateless tier is always compatible with the state it is talking to. The mental model that keeps you safe is simple: stateless services are disposable and you replace them freely, stateful ones are precious and you change them with intention, and the two get different deploy treatment even when they live in the same compose file.

The supporting cast

Images build in CI and push to a registry, so a deploy is just pointing the service at a new tag. Secrets come from a secrets manager and are injected at runtime, never baked into the image, because an image is a tarball anyone with pull access can crack open. A self-hosted runner performs the deploy and pings a channel when it starts and finishes, so a deploy is an observable event rather than a silent SSH session nobody else can see. And you actually test rollback, on purpose, before you need it, because a rollback path you have never exercised is a rollback path that does not work when the room is on fire.

Watch the golden signals during a rollout

A deploy is the single most likely moment for something to break, so it is the moment you watch most closely, and a rollout nobody is watching is a rollout that fails silently until a user complains. You keep eyes on the golden signals, latency, error rate, traffic, and saturation, specifically during the window when old and new replicas coexist, because that is when a subtle incompatibility shows itself as a creeping error rate on exactly the fraction of traffic hitting the new version. The healthcheck tells you a replica is willing to take traffic; the golden signals tell you whether it is actually serving that traffic correctly, and those are not the same thing. Wire the deploy so that an error spike on the new replicas trips the same automatic rollback the failed healthcheck would, and you have closed the loop: the system catches its own bad deploys faster than a human watching a dashboard ever could.

When to graduate

Swarm gives you less than Kubernetes: no rich autoscaling, a thinner ecosystem, fewer operators and add-ons. In exchange it gives you something one person can actually operate and reason about end to end, which for a handful of services is the better trade by a wide margin. The right time to graduate to Kubernetes is when you genuinely need what it offers, real horizontal autoscaling, multi-tenant scheduling, the operator ecosystem, and not because a blog post or a job description said you had to. I have set this Swarm pattern up enough times across projects that I eventually turned it into a product, precisely because most teams need the result, not the complexity.

Enjoyed this? Let me know

0claps