Every deployment guide eventually tells you to use Kubernetes. For a team of 2-5 developers running a handful of services, Kubernetes is like buying a commercial airplane to commute to work — technically capable of the job, but vastly over-engineered, expensive to maintain, and requiring specialized expertise that your team does not have and should not need.
Zero-downtime deployment — the ability to update your application without any user experiencing an error or interruption — is achievable with tools you already have: Docker Compose for container management, Nginx for load balancing, and a deployment script that orchestrates the update. This guide shows you how to implement it step by step.
The Problem with Simple Deployments
The naive deployment process is: stop the old container, build or pull the new image, start the new container. During the time between stopping the old container and the new container being ready to serve requests (typically 5-30 seconds), users see errors. For a marketing website, this might be acceptable. For a SaaS application, an e-commerce store, or an API that other services depend on, even 10 seconds of downtime causes failed transactions, broken user experiences, and lost revenue.
# The naive (downtime) deployment
docker compose down
docker compose pull
docker compose up -d
# 10-30 seconds of downtime between down and up
Strategy 1: Rolling Deployment with docker compose
Docker Compose supports scaling services to multiple instances. The rolling deployment strategy starts new containers alongside the old ones, waits for the new containers to become healthy, and then removes the old containers. At every moment during the deployment, at least one healthy container is serving requests.
First, configure your docker-compose.yml with health checks:
services:
web:
image: myapp:latest
deploy:
replicas: 2
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
ports:
- "127.0.0.1:3000-3001:3000" # Range of ports for multiple instances
The rolling deploy script:
#!/bin/bash
set -e
APP_NAME="web"
COMPOSE_FILE="docker-compose.production.yml"
echo "==> Pulling latest image..."
docker compose -f $COMPOSE_FILE pull $APP_NAME
echo "==> Starting new instances alongside old ones..."
docker compose -f $COMPOSE_FILE up -d --no-deps --scale $APP_NAME=4 $APP_NAME
echo "==> Waiting for new instances to be healthy..."
sleep 20
# Check health of new containers
HEALTHY=$(docker compose -f $COMPOSE_FILE ps $APP_NAME | grep -c "healthy" || true)
echo "==> Healthy instances: $HEALTHY"
if [ "$HEALTHY" -lt 2 ]; then
echo "==> ERROR: New instances are not healthy. Rolling back..."
docker compose -f $COMPOSE_FILE up -d --no-deps --scale $APP_NAME=2 $APP_NAME
exit 1
fi
echo "==> Scaling down to desired count..."
docker compose -f $COMPOSE_FILE up -d --no-deps --scale $APP_NAME=2 $APP_NAME
echo "==> Removing old images..."
docker image prune -f
echo "==> Deployment complete!"
This script doubles the number of instances (from 2 to 4), waits for the new instances to pass health checks, and then scales back down to 2. The old containers are removed while the new ones are already serving traffic. Users never see an interruption.
Strategy 2: Blue-Green Deployment with Nginx
Blue-green deployment runs two complete environments: "blue" (current production) and "green" (new version). Traffic is switched from blue to green instantaneously by updating Nginx's upstream configuration.
# docker-compose.yml with blue and green environments
services:
blue:
image: myapp:v1.0
ports:
- "127.0.0.1:3001:3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
green:
image: myapp:v1.1
ports:
- "127.0.0.1:3002:3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
Nginx configuration that switches between environments:
# /etc/nginx/conf.d/upstream.conf
# This file is dynamically updated during deployments
upstream app_backend {
server 127.0.0.1:3001; # Blue environment (current)
}
# /etc/nginx/sites-available/myapp.conf
server {
listen 443 ssl http2;
server_name myapp.com;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The deployment script for blue-green:
#!/bin/bash
set -e
COMPOSE_FILE="docker-compose.production.yml"
NGINX_UPSTREAM="/etc/nginx/conf.d/upstream.conf"
# Determine current and target environments
CURRENT=$(grep -o "300[12]" $NGINX_UPSTREAM | head -1)
if [ "$CURRENT" = "3001" ]; then
TARGET="green"
TARGET_PORT="3002"
CURRENT_ENV="blue"
else
TARGET="blue"
TARGET_PORT="3001"
CURRENT_ENV="green"
fi
echo "==> Current: $CURRENT_ENV | Deploying to: $TARGET"
# Pull new image and start target environment
echo "==> Starting $TARGET environment..."
docker compose -f $COMPOSE_FILE pull $TARGET
docker compose -f $COMPOSE_FILE up -d $TARGET
# Wait for health check
echo "==> Waiting for $TARGET to be healthy..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:$TARGET_PORT/health > /dev/null 2>&1; then
echo "==> $TARGET is healthy!"
break
fi
if [ "$i" -eq 30 ]; then
echo "==> ERROR: $TARGET failed health check. Aborting."
docker compose -f $COMPOSE_FILE stop $TARGET
exit 1
fi
sleep 2
done
# Switch Nginx to target environment
echo "==> Switching traffic to $TARGET..."
cat > $NGINX_UPSTREAM < Traffic switched to $TARGET"
# Wait for existing connections to drain
echo "==> Draining connections from $CURRENT_ENV..."
sleep 10
# Stop old environment
echo "==> Stopping $CURRENT_ENV..."
docker compose -f $COMPOSE_FILE stop $CURRENT_ENV
echo "==> Deployment complete! Active environment: $TARGET"
The key advantage of blue-green is instant rollback. If the new version has problems after deployment, switch Nginx back to the old environment with a single command — the old containers are still running.
Strategy 3: Graceful Shutdown and Connection Draining
Regardless of which deployment strategy you use, your application must handle graceful shutdown. When a container receives SIGTERM (which Docker sends when stopping a container), it should: stop accepting new connections, finish processing in-flight requests, close database connections cleanly, and then exit.
In Node.js:
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received. Graceful shutdown...');
server.close(() => {
console.log('All connections closed.');
process.exit(0);
});
// Force shutdown after 30 seconds if connections don't close
setTimeout(() => {
console.error('Forced shutdown after timeout.');
process.exit(1);
}, 30000);
});
In your docker-compose.yml, set the stop grace period to give the application time to drain connections:
services:
web:
image: myapp:latest
stop_grace_period: 30s # Wait 30s for graceful shutdown before SIGKILL
Health Check Endpoint Design
Your health check endpoint should verify that the application is genuinely ready to serve requests, not just that the HTTP server is listening. A good health check verifies:
// /health endpoint
app.get('/health', async (req, res) => {
try {
// Check database connection
await db.query('SELECT 1');
// Check Redis connection
await redis.ping();
// Check available memory
const memUsage = process.memoryUsage();
const heapUsedPercent = memUsage.heapUsed / memUsage.heapTotal;
if (heapUsedPercent > 0.95) {
return res.status(503).json({ status: 'degraded', reason: 'high memory usage' });
}
res.status(200).json({ status: 'healthy', uptime: process.uptime() });
} catch (error) {
res.status(503).json({ status: 'unhealthy', error: error.message });
}
});
Automated Rollback
Production deployments must include automatic rollback. After switching to the new version, monitor error rates for a defined period. If errors exceed a threshold, automatically roll back:
#!/bin/bash
# Post-deployment monitoring (add to the end of deploy script)
echo "==> Monitoring for errors (60 seconds)..."
sleep 60
# Check HTTP error rate
ERROR_COUNT=$(curl -s http://127.0.0.1:3000/metrics | grep 'http_errors_total' | awk '{print $2}')
if [ "$ERROR_COUNT" -gt 10 ]; then
echo "==> ERROR: High error rate detected ($ERROR_COUNT errors). Rolling back..."
# Revert Nginx to previous environment
cat > $NGINX_UPSTREAM < Rollback complete. Investigate the failed deployment."
exit 1
fi
echo "==> Deployment verified. No elevated errors."
Zero-downtime deployment does not require Kubernetes, Helm charts, service meshes, or a platform team. A well-written shell script, Docker Compose, and Nginx give you rolling deployments with health checks and automatic rollback — everything a small team needs to deploy confidently to production.
ZeonEdge implements zero-downtime deployment pipelines for teams that need reliability without Kubernetes complexity. Learn more about our deployment services.
Marcus Rodriguez
Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.