Zero-Downtime Deployments Without Kubernetes: Blue-Green and Sibling-Container Patterns
Deploy new code without dropping requests using blue-green deployments, sibling containers, and connection draining on single Docker hosts.
A production deploy should be boring: the user doesn’t notice, connections don’t drop, and no one gets paged. Most teams achieve this with Kubernetes rolling updates or a load balancer, but you don’t need that machinery on a single host. The sibling-container pattern runs a new version alongside the old one, waits for the reverse proxy to discover it, then kills the old one. Done right, the window where requests might fail is measured in milliseconds.
This guide explains the pattern in detail, shows you the exact sequence, covers the edge cases (especially long-lived WebSocket connections), and walks you through alternative approaches like blue-green deployments and feature-flagged canaries.
The Sibling-Container Pattern: How It Works
The idea is simple: you’re going to have two containers of the same service running at the same time for a brief moment. The reverse proxy routes to both of them. Then you remove the old one.
Here’s the sequence:
- Start a temporary container with the new code, marked with the same Traefik labels as the real service.
- Wait 2-4 seconds for Traefik to notice the temporary container via Docker events and add it to the routing pool.
- Recreate the main service with
docker compose up -d --no-deps <service>. This kills the old container and starts a new one (which is the same as the temporary one). - Remove the temporary container with
docker compose kill <service>-temp && docker compose rm <service>-temp, but give it a grace period so existing connections can drain.
The key insight: steps 2-3 have both old and new code running. Traefik sees both and routes new requests to both. Old connections stay on the old container, new connections can go to either. When you kill the old container, any remaining old connections get a grace period to close cleanly instead of being cut abruptly.
Why This Matters for WebSocket Connections
HTTP requests are stateless: the connection opens, the request completes, the connection closes. If you kill a service during a request, the user gets an error, but they can retry.
WebSocket connections are long-lived. A video conference, a real-time collaboration tool, or a live game all run on persistent sockets. If you kill the container while a socket is open, the connection is severed without warning. The client doesn’t know why and has to reconnect from scratch, losing context.
With the sibling-container pattern, you give the old container a grace period (default 10 seconds, often raised to 30 seconds) to drain connections. During that time:
- The reverse proxy stops routing new requests to the old container.
- Old requests and WebSocket connections have time to finish.
- Only after the grace period expires is the container killed.
This is why you see StopGracePeriod: 30s in compose files for services with WebSockets.
The Full Deploy Sequence: Shell Script
Here’s a concrete bash example. Imagine you’re deploying a new version of api.
#!/bin/bash
set -e
SERVICE="api"
IMAGE="myapp:v2.0.0" # New image
TEMP_SERVICE="${SERVICE}-deploy-temp"
GRACE_PERIOD=30 # Seconds to wait before killing old container
echo "Starting sibling container with new image..."
docker run --name "$TEMP_SERVICE" \
-d \
--network compose_app \
--label "traefik.enable=true" \
--label "traefik.http.routers.${SERVICE}.rule=Host(\`api.example.com\`)" \
--label "traefik.http.routers.${SERVICE}.entrypoints=websecure" \
--label "traefik.http.services.${SERVICE}.loadbalancer.server.port=3000" \
"$IMAGE"
echo "Waiting for reverse proxy to discover the sibling..."
sleep 4
echo "Recreating the main service (old container dies, new one starts)..."
docker compose up -d --no-deps "$SERVICE"
echo "Waiting for old container to finish draining connections..."
sleep 2
echo "Stopping sibling container with grace period..."
docker kill --signal=SIGTERM "$TEMP_SERVICE" || true
sleep "$GRACE_PERIOD"
echo "Removing sibling container..."
docker rm "$TEMP_SERVICE" || true
echo "Deploy complete!"
This script does the entire dance in about 40 seconds (4 for discovery, 2 for drain, 30 for grace period). If you set a StopGracePeriod in your docker-compose.yml, the docker compose up and docker kill commands will respect it automatically.
In a docker-compose.yml
To make this repeatable, define both the main service and a template for the temporary service:
version: "3.9"
services:
api:
image: myapp:latest
container_name: api
expose:
- 3000
stop_grace_period: 30s # Give connections 30s to drain
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.services.api.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
Then in your CI/CD:
#!/bin/bash
# deploy.sh: called by CI/CD with the new image tag
set -e
SERVICE="api"
IMAGE="myapp:${CI_COMMIT_SHA:0:8}"
TEMP_SERVICE="${SERVICE}-deploy-temp"
# Extract compose network from running container
NETWORK=$(docker inspect "$SERVICE" -f '{{.HostConfig.NetworkMode}}')
echo "Starting sibling container..."
docker run --name "$TEMP_SERVICE" \
-d \
--network "$NETWORK" \
--label "traefik.enable=true" \
--label "traefik.http.routers.${SERVICE}.rule=Host(\`api.example.com\`)" \
--label "traefik.http.services.${SERVICE}.loadbalancer.server.port=3000" \
"$IMAGE"
echo "Waiting 4s for Traefik discovery..."
sleep 4
echo "Updating main service..."
docker compose up -d --no-deps --build "$SERVICE"
echo "Stopping sibling..."
docker stop -t 30 "$TEMP_SERVICE" && docker rm "$TEMP_SERVICE"
echo "Zero-downtime deploy complete"
Detecting When the Old Container Has Drained
A 30-second grace period is conservative. In practice, most connections drain in 2-5 seconds. If you want to be more aggressive, you can monitor the old container and remove it as soon as it’s empty:
#!/bin/bash
wait_for_drain() {
local container_id=$1
local timeout=30
local elapsed=0
while [ $elapsed -lt $timeout ]; do
# Check if any connections are still open
local open_conns=$(docker exec "$container_id" \
lsof -p $(docker inspect -f '{{.State.Pid}}' "$container_id") 2>/dev/null | \
grep ESTABLISHED | wc -l)
if [ "$open_conns" -eq 0 ]; then
echo "All connections drained after ${elapsed}s"
return 0
fi
echo "Waiting for drain... ($open_conns open connections)"
sleep 1
((elapsed++))
done
echo "Timeout: force-killing after ${timeout}s"
return 1
}
# Usage
docker stop -t 0 "$TEMP_SERVICE" # Don't wait for graceful shutdown
wait_for_drain "$TEMP_SERVICE"
docker rm "$TEMP_SERVICE"
Blue-Green Deployments: Complete Environment Swap
Another zero-downtime approach is blue-green: run two complete environments (blue and green), switch the reverse proxy to point to the new one, keep the old one running for quick rollback.
version: "3.9"
services:
# Blue environment (old)
api-blue:
image: myapp:v1.0.0
container_name: api-blue
expose:
- 3000
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.example.com`)"
- "traefik.http.services.api.loadbalancer.server.port=3000"
- "traefik.http.services.api.loadbalancer.server.url=http://api-blue:3000"
# Green environment (new)
api-green:
image: myapp:v2.0.0
container_name: api-green
expose:
- 3000
labels:
- "traefik.enable=false" # Start disabled
# Same labels, but Traefik won't route to it yet
To switch from blue to green:
#!/bin/bash
# 1. Update the green environment to the new code
docker compose pull api-green && docker compose up -d api-green
# 2. Test the green environment (optional but recommended)
curl http://localhost:3000/health # or test endpoint
# 3. Enable green in Traefik by removing the disable label
docker compose exec api-green \
sh -c 'echo "ready" > /tmp/ready'
# 4. Update Traefik routing to point to green
docker exec traefik traefik-ctl routers update api \
--service api-green # This is conceptual; actual command depends on Traefik version
# 5. If something goes wrong, switch back to blue immediately
docker exec traefik traefik-ctl routers update api \
--service api-blue
With Traefik, you can implement this by updating labels on-the-fly (though it requires a bit of extra tooling). Nginx requires a config reload.
Advantages: instant rollback, time to verify in production before switching. Disadvantages: double the resources, more complex orchestration, requires careful state synchronization if you have databases.
Canary Deployments: Feature-Flagged or Weighted Routing
Canary deployments push new code to a small percentage of traffic, monitor for errors, then gradually increase the percentage.
Approach 1: Feature Flags (Application Layer)
Your code reads a feature flag and routes internal behavior. Traffic goes to the same container, but some users get new code, some get old.
# In your application
if (feature_flag('use_new_algorithm')) {
return new_algorithm();
} else {
return old_algorithm();
}
Advantage: instant rollback without redeploying. Disadvantage: requires application changes.
Approach 2: Weighted Routing (Reverse Proxy)
Tell the reverse proxy to send 10% of traffic to the new service, 90% to the old.
With Traefik, you can define weights:
services:
api-old:
labels:
- "traefik.http.services.api.loadbalancer.servers[0].url=http://api-old:3000"
- "traefik.http.services.api.loadbalancer.servers[0].weight=9"
api-new:
labels:
- "traefik.http.services.api.loadbalancer.servers[1].url=http://api-new:3000"
- "traefik.http.services.api.loadbalancer.servers[1].weight=1"
This splits 10 requests across the pool: 9 to old, 1 to new.
Advantage: transparent to the application. Disadvantage: requires reverse proxy support and careful monitoring.
What to Monitor During Deploy
Watch these metrics during the deploy window:
# Monitor HTTP error rate (should stay 0)
curl http://localhost:8080/health # Healthcheck endpoint
# Monitor active connections
netstat -an | grep ESTABLISHED | wc -l
# Monitor container restart count
docker stats | grep "$SERVICE"
Set up alerts to page you if:
- Error rate spikes during deploy
- HTTP 5xx responses increase
- Connection count doesn’t decrease after the old container stops
- The new container doesn’t become healthy
When Sibling Containers Fail
Common failure modes:
-
New container crashes during deploy — if the healthcheck fails, Traefik won’t route to it, and all traffic stays on the old container. No outage, but deploy fails.
-
Temporary container is marked as unhealthy, old is killed first — if both containers are unhealthy, Traefik route order matters. Ensure the temporary container is up and healthy before recreating the main service.
-
Grace period is too short for real workloads — WebSocket connections or long-running requests get cut. Increase
StopGracePeriodor use drain detection. -
No shared state between containers — if your service stores session state in memory, switching containers loses all sessions. Use Redis or a database.
Key Takeaways
- Sibling-container pattern avoids downtime — new version runs alongside old, reverse proxy routes to both, old one drains then stops
- Grace period drains connections — WebSocket and long-lived requests get time to close cleanly
- Traefik discovers containers automatically — any delay waiting for discovery is unavoidable with dynamic discovery
- Reverse proxy routing order matters — test that the new container is healthy before killing the old one
- Blue-green swaps complete environments — more resources but instant rollback, good for high-stakes deploys
- Weighted canary routing splits traffic — low-risk rollout if your reverse proxy supports it
- Feature flags are the safest canary — instant rollback, no infrastructure changes, but requires code support
- Monitor error rate and connection count — any deploy without monitoring is gambling
- Document your deploy procedure — the exact sequence matters, and disaster recovery is clearer with docs
- Test deploys in staging first — the worst time to learn your deploy procedure breaks is production