Most articles about deployment strategies assume you have Kubernetes, Istio, and a platform team. Most teams I work with have two or three developers, a couple of VPSs, nginx, and Docker. The good news nobody tells them: blue-green and canary deployments are routing patterns, not Kubernetes features. Everything you need to run both safely exists in the nginx and Docker Compose you already operate.
I have shipped both patterns for small Indonesian SMB clients and on my own Docker Swarm boxes. In this post I will define each strategy precisely, show minimal working implementations on a single VPS, compare them on the criteria that actually matter at small scale — cost, rollback speed, and observability requirements — and give you a decision rule that fits on a sticky note.
The Two Strategies in One Minute
Blue-green
Run two identical environments. Blue serves all traffic while you deploy and smoke-test green in private. Cutover is one router change sending 100 percent of traffic to green; rollback is switching back. Martin Fowler documented the pattern long before container orchestrators existed.
Canary
Deploy the new version next to the old one, route a small slice of real users to it, watch error rates and latency, then progressively widen the slice until the old version drains. Rollback is rerouting the slice back to stable. Risk is reduced by exposure control, not by an instant switch.
Notice what both definitions share: two versions running simultaneously and a router deciding who sees what. That router does not have to be a service mesh. On a budget, it is nginx with an upstream block — which is exactly the implementation we will build.
Blue-Green on One VPS with Nginx and Compose
My minimal blue-green is two Docker Compose projects, app-blue on port 3001 and app-green on port 3002, behind a single nginx upstream. The deploy script is short enough to read in full:
# Two compose projects, one nginx upstream
# /etc/nginx/conf.d/app-upstream.conf
upstream app_backend {
server 127.0.0.1:3001; # blue (live)
# server 127.0.0.1:3002; # green (idle)
}
# deploy.sh — the whole "platform"
docker compose -p app-green up -d --build # start new version on :3002
./smoke-test.sh http://127.0.0.1:3002 # verify before any user sees it
sed -i 's/3001/3002/' /etc/nginx/conf.d/app-upstream.conf
nginx -t && nginx -s reload # atomic cutover
docker compose -p app-blue stop # keep it around for rollbackThree properties make this worth the modest extra RAM of running two app instances briefly:
- Users never see a half-deployed state. The smoke test runs against the real production build, on production hardware, before the reload — failures are caught while green has zero traffic.
- Rollback is the same one-line operation in reverse, and it takes seconds. No image rebuild, no git revert under pressure, just point nginx back at blue.
- An nginx reload is graceful: existing connections finish on the old worker processes while new connections hit the new upstream. On HTTP workloads the cutover is effectively invisible.
Canary Without a Service Mesh: Weighted Upstreams
Canary releases sound like they need Istio's traffic splitting, but nginx has shipped weighted load balancing for two decades. The same two-instance layout becomes a canary with one directive:
# Weighted canary with plain nginx — no service mesh
upstream app_backend {
server 127.0.0.1:3001 weight=9; # stable: 90% of traffic
server 127.0.0.1:3002 weight=1; # canary: 10% of traffic
}
# promote by shifting weights: 9/1 -> 5/5 -> 0/1
# rollback = comment out the canary line and reloadPromotion is editing weights and reloading: I typically go 10 percent for an hour, 50 percent for a few hours, then 100. The catch nobody mentions: a weighted canary is only as good as your ability to compare the two versions. If you cannot tell canary errors from stable errors, you are not canarying — you are gambling slowly. I label metrics and logs by upstream port in Prometheus and Loki so the comparison is one Grafana panel: error rate and p95 latency, stable versus canary, side by side.
Sticky sessions are the classic small-team canary bug: with plain round-robin weights, the same user can bounce between versions on every request. If your app keeps server-side session state or your frontend hashes differ per version, add ip_hash or a sticky cookie so each user consistently sees one version during the rollout.
Head-to-Head for Small Teams
Both patterns beat deploy-and-pray. They differ in what they cost you and what they demand of your monitoring:
| Criterion | Blue-green | Canary |
|---|---|---|
| Extra infrastructure | Second environment during deploys only; can stop blue after a soak period. | Both versions run for the whole rollout window, hours to days. |
| Rollback speed | Seconds — one upstream edit and reload. | Seconds for the canary slice; affected users limited to the slice from the start. |
| Blast radius of a bad deploy | Everyone, instantly, until you switch back. | Only the canary percentage — 10 percent of users at 10 percent weight. |
| Monitoring needed | A smoke test and basic uptime checks suffice. | Per-version error and latency metrics, or the whole exercise is theater. |
| Best for | Most small-team releases; schema-coupled changes; teams without per-version metrics. | Risky changes on busy services: query rewrites, runtime upgrades, payment flows. |
The Database Is the Hard Part in Both
Both strategies put two app versions against one database, so the schema must support both at once. Fowler's advice predates Kubernetes and still beats every tool: decouple schema changes from code deploys with expand-and-contract. The sequence is mechanical:
- Expand: apply an additive migration — new nullable column, new table, new index. Old code ignores it, new code can use it. Deploy this alone and verify.
- Deploy the new application version with blue-green or canary, while old and new code both work against the expanded schema.
- Migrate data in the background if needed, in small batches that do not lock production tables.
- Contract: only after the new version has soaked and the old environment is retired, drop the old columns in a separate migration. Never expand and contract in the same release.
Pro tip: rehearse the rollback, not just the deploy. Once a quarter I switch production back to the previous version for five minutes during a quiet window. The first rehearsal found a hardcoded port in a health check that would have turned a clean rollback into a 20-minute outage during a real incident.
My Decision Rule
- Default to blue-green. It is simpler to reason about, needs no per-version observability, and covers the overwhelming majority of releases for a two-pizza team.
- Reach for canary when the change is genuinely risky and the service is busy enough that a 10 percent slice produces statistically meaningful signal within an hour or two.
- If you cannot yet split metrics by version, you cannot canary — invest in labeled Prometheus metrics first, or stay with blue-green honestly.
- Never mix a schema contract with either strategy. Expand-and-contract makes both patterns safe; skipping it makes both patterns dangerous.
The Takeaway
Deployment safety is a property of your routing and your discipline, not your orchestrator's price tag. A 4-dollar VPS running nginx in front of two Compose projects gives you the same fundamental guarantees that companies buy service meshes for: zero-downtime cutover, instant rollback, and controlled exposure.
Start with the blue-green script above — it is an afternoon of work including the smoke test. Add weighted canaries the day you ship something that genuinely scares you. And whichever you choose, do the expand-and-contract dance on the database, because that is where deployment strategies actually fail.
Sources and further reading

