← Back to Writing
DevOps Docker CI/CD

Blue-Green on One Box: Zero-Downtime Deploys When You Can't Afford a Warm Standby

June 12, 2026 · 8 min read

My production deploys used to be one command: docker compose up -d --remove-orphans. It rebuilds the changed containers in place, which is fine right up until the thing you are deploying has users on it. For a few seconds capacity dips, in-flight requests can die, and if the new image is broken your rollback is to rebuild the previous commit and push again. I run a deployment platform, Reoclo, so deploying it badly is a particularly bad look. I wanted real blue-green: bring the new version up next to the old one, prove it is healthy, then cut traffic over atomically, with a rollback measured in seconds. The catch is that every blue-green tutorial quietly assumes you have a second server, or at least the RAM to run two full copies of your app at once. I had one box and a budget that did not stretch to a second.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-monospace, monospace','primaryColor':'#1b2430','primaryTextColor':'#e8edf2','primaryBorderColor':'#3d5168','lineColor':'#6b7f99','clusterBkg':'transparent','clusterBorder':'#2a3645'}}}%% flowchart TD CI["CI: build new image"] --> UP["Bring up inactive color<br/>at reduced replicas"] UP --> HG{"Health gate<br/>before any flip"} HG -->|passes| FLIP["Render Caddyfile,<br/>caddy reload (zero-drop)"] HG -->|fails| TD["Tear new color down,<br/>pipeline goes red"] FLIP --> STOP["Stop old color,<br/>retain for rollback"] STOP --> SCALE["Scale new color to full"] SCALE --> SMOKE["Post-flip smoke"] SMOKE --> LIVE["Live on new color"] classDef gate fill:#3a2f12,stroke:#d6a32e,stroke-width:2px,color:#f4e6c2; classDef live fill:#10301d,stroke:#43c282,stroke-width:2px,color:#bff0d6; classDef fail fill:#34161a,stroke:#e0666f,stroke-width:1.5px,color:#f4c6cb; class HG gate; class LIVE live; class TD fail;

What gets a color, and what stays put

The first decision is what actually flips, because not everything can. My stateless app tier (the API, the gateway, the web and auth frontends, the docs and marketing sites, the websocket relay) is safe to run as two versions for a few seconds, so each of those gets a color: every service comes up twice, once as api-blue and once as api-green, and so on down the list. The infrastructure underneath does not flip. Caddy is the router that performs the cutover, so it has to stay up through the cutover, not get recreated by it. RabbitMQ, MinIO, and Loki each own state, and running two copies would split that state in two. Mongo is already external and shared by both colors. So the rule is simple: stateless things get a color and swap, stateful things stay singleton and survive every deploy. Blue-green is a property of your stateless tier, not your whole stack.

The flip is just a config reload

The cutover itself is the part people overcomplicate. I render Caddy's config with the active color baked into every upstream (api-green:8000 instead of api:8000), then run caddy validate followed by caddy reload. The reload is graceful: Caddy serves new connections on the new config while existing ones drain, so no request is dropped and nothing restarts. A single state file, active_color, records which color is live. I looked at two flashier options and rejected both. Caddy's Admin API can patch routes atomically, but it turns every reverse-proxy directive into a JSON path you have to keep in sync as the config grows. Swapping a Docker network alias sounds clean until you learn Docker cannot reassign a live alias atomically: you disconnect and reconnect, and for a moment the name points at nothing. A validated config reload is boring, which on a deploy path is exactly what you want.

Warm standby that fits in 8 GB

Now the constraint that shaped everything else. The textbook picture of blue-green has both colors running hot side by side, so a flip is instant and a rollback is even faster. Two full copies of my app tier do not fit on a single 8 GB box, and I deliberately chose to fit the box rather than pay for a bigger one. So warm here does not mean hot. The new color comes up at reduced replicas (one of each instead of two, the worker held at zero) purely so it can be validated, which keeps the peak at roughly 1.4 times the app tier instead of 2. After the flip, the old color is not deleted, it is stopped: its containers and its old image stay on disk, costing nothing but space. Rollback then resumes them with docker compose start, which takes seconds because there is nothing to pull and nothing to build. It is not instant the way a hot standby is, but it is close, and it is the difference between fitting on the hardware I have and not shipping at all.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-monospace, monospace','lineColor':'#6b7f99'}}}%% stateDiagram-v2 [*] --> Blue: first deploy Blue --> GreenValidating: deploy, green up reduced GreenValidating --> Blue: health gate fails, green torn down GreenValidating --> Green: flip Caddy, stop blue Green --> BlueValidating: deploy, blue up reduced BlueValidating --> Green: health gate fails, blue torn down BlueValidating --> Blue: flip Caddy, stop green Green --> Blue: rollback, resume stopped blue Blue --> Green: rollback, resume stopped green style Blue fill:#13314f,stroke:#4a90d6,stroke-width:2px,color:#cfe6ff style Green fill:#10301d,stroke:#43c282,stroke-width:2px,color:#bff0d6 style GreenValidating fill:#3a2f12,stroke:#d6a32e,stroke-width:1.5px,color:#f4e6c2 style BlueValidating fill:#3a2f12,stroke:#d6a32e,stroke-width:1.5px,color:#f4e6c2

Two gates, two automatic rollbacks

A flip you cannot trust is worse than no flip, so traffic moves only after the new color proves itself, and it gets two chances to fail safely. Before the flip, a throwaway curl container on the shared network polls every service: the API and gateway health endpoints, a real response from each frontend, a TCP connect to the websocket relay. If any of them does not come good within the timeout, the deploy tears the new color down and stops. The old color never moved, nobody noticed, and the pipeline simply goes red. That is path A, and it is the common case for a bad build. The second gate runs after the flip: a smoke check that hits the actual public domain through the live edge, end to end, the way a real user would. If that fails, the deploy rolls itself back, resuming the old color and flipping Caddy back to it, with no human in the loop. That is path B. The health gate catches a broken image; the smoke check catches a wiring problem that only shows up once real traffic is flowing.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-monospace, monospace','primaryColor':'#1b2430','primaryTextColor':'#e8edf2','primaryBorderColor':'#3d5168','lineColor':'#6b7f99'}}}%% flowchart TD NEW["New color up,<br/>reduced replicas"] --> HG{"Health gate<br/>before any flip"} HG -->|fails| PA["Path A: tear new color down.<br/>Old color untouched, no traffic moved"] HG -->|passes| FLIP["Flip Caddy to new color,<br/>stop old color"] FLIP --> SMOKE{"Post-flip smoke<br/>through public edge"} SMOKE -->|fails| PB["Path B: resume old color,<br/>flip Caddy back"] SMOKE -->|passes| LIVE["Deploy complete"] classDef gate fill:#3a2f12,stroke:#d6a32e,stroke-width:2px,color:#f4e6c2; classDef live fill:#10301d,stroke:#43c282,stroke-width:2px,color:#bff0d6; classDef fail fill:#34161a,stroke:#e0666f,stroke-width:1.5px,color:#f4c6cb; class HG gate; class SMOKE gate; class LIVE live; class PA fail; class PB fail;

The honest parts

Blue-green on one box is not free, and pretending otherwise is how you get surprised. Between the flip and the scale-up, the new color briefly serves at a single replica per service. That is reduced capacity for a few seconds, not downtime, and it is the price of not running two hot copies. If you have the RAM for a second hot tier, take it and skip this particular compromise. The discipline that bites hardest has nothing to do with colors: while both versions can touch the same external database (during validation and through the rollback window), every schema change has to be backward-compatible. You expand, then contract, across separate deploys, the same migration hygiene any rolling deploy demands. Long-lived connections (terminal sessions, websockets, server-sent event streams) pinned to the old color drop when it stops, which is acceptable only because the clients already reconnect and land on the new color. And the rule that saves you at 2am: a rollback path you have never run is not a rollback path, so I forced both failure modes on purpose before I trusted either.

The constraint made it better

What surprised me is that the limit improved the design instead of hurting it. Forcing everything onto one box meant the whole thing had to be small enough to hold in my head: one router that reloads, one state file, two colors, two gates, and one stopped copy waiting to be resumed. There is no cluster to operate, no control plane to patch, and when a deploy goes wrong it fixes itself faster than I could open a dashboard. Rolling updates get you most of the way with even less machinery; blue-green earns its keep when you want one deterministic, atomic cutover and a rollback you can trigger by hand. You do not need a second server, or Kubernetes, to deploy without dropping traffic. You need to decide what is allowed to flip, make the cutover boring, and never move traffic onto something you have not checked.

Enjoyed this? Let me know

0claps