Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Zero-downtime deploys without Kubernetes, step by step How to ship during working hours on one or two servers: blue-green with NGINX, real health checks, graceful shutdown and migrations that old code survives.
A team pushes a small fix at 11am. CI goes green, the deploy script runs docker compose up -d, and for the next eight seconds every request returns 502. The team stops deploying during the day, releases get bigger, and each one gets riskier.
You do not need Kubernetes to fix this. Zero-downtime deploys without Kubernetes rest on four rules: start the new version before stopping the old one, send it traffic only after a real health check passes, switch traffic at a proxy that reloads gracefully, and let the old version finish its in-flight requests. Database changes must work with both versions.
Here is how we do it with Docker Compose and NGINX on ordinary Linux servers.
Where the downtime actually comes from Most setups have more than one of these gaps:
The old process stops before the new one accepts connections. A plain docker compose up -d on a changed service stops and removes the old container, then creates the new one. The new process listens before it is ready, so early requests fail or hang. The old process is killed mid-request. Docker sends SIGTERM, waits 10 seconds by default, then sends SIGKILL. If the app ignores SIGTERM, every open request is cut off. A schema change breaks the old code while it is still running. The frontend references asset files that no longer exist, so users with the old page open hit errors when they load the next chunk. Fixing only the first leaves odd failures that are harder to trace.
Pick a pattern: blue-green or rolling Blue-green keeps two copies of the application, one live and one idle. You deploy to the idle copy, check it, then point the proxy at it. Rollback is pointing the proxy back. The cost is double the application's memory during a deploy.
Rolling replaces replicas one at a time behind a load balancer. Docker Swarm, which Dokploy uses underneath, has it built in:
services:
api:
image: registry.example.com/erp-api:${API_TAG}
deploy:
replicas: 2
update_config:
parallelism: 1
order: start-first
delay: 10s
failure_action: rollback
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20sThe key line is order: start-first, which starts the new task before stopping the old one. update_config is only honoured by docker stack deploy on Swarm; plain docker compose up ignores it and does not do rolling updates. The health check needs curl in the image.
For plain Compose we prefer blue-green. It is easy to reason about and rollback is instant.
Blue-green with Docker Compose and NGINX We run one Compose file as two projects, erp-blue and erp-green, each on its own local port. The database and Redis live in a separate project that deploys never touch.
# /srv/erp/compose.yml
services:
api:
image: registry.example.com/erp-api:${API_TAG}
env_file: .env.api
restart: unless-stopped
init: true
stop_grace_period: 30s
ports:
- "127.0.0.1:${HOST_PORT}:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
retries: 3NGINX reads the active upstream from a one-line file, pulled in with include inside the upstream block. The deploy script only ever rewrites that file, never the site config.
# /etc/nginx/erp-upstream.conf (rewritten by the deploy script)
server 127.0.0.1:8001;
# /etc/nginx/sites-available/erp.conf
upstream erp_api {
include /etc/nginx/erp-upstream.conf;
keepalive 16;
}
server {
listen 443 ssl;
server_name erp.example.com;
# ssl_certificate and ssl_certificate_key lines omitted
location /api/ {
proxy_pass http://erp_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The switch works because an NGINX reload is graceful. Old workers stop accepting connections but finish the requests they are serving. With long-lived WebSocket connections, set worker_shutdown_timeout in the main context so old workers do not linger for hours.
CI runs this script over SSH with the image tag as its argument. The deploy user needs passwordless sudo for tee, nginx -t and the reload, and nothing else.
#!/usr/bin/env bash
set -euo pipefail
TAG="$1"
cd /srv/erp
ACTIVE=$(cat active_colour 2>/dev/null || echo blue)
if [ "$ACTIVE" = "blue" ]; then NEW=green; PORT=8002; else NEW=blue; PORT=8001; fi
# 1. Start the idle colour on the new version
API_TAG="$TAG" HOST_PORT="$PORT" docker compose -p "erp-$NEW" up -d --pull always api
# 2. Wait until it answers its health check
healthy=false
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null; then healthy=true; break; fi
sleep 2
done
if [ "$healthy" != true ]; then
echo "erp-$NEW did not become healthy; traffic left on $ACTIVE" >&2
docker compose -p "erp-$NEW" logs --tail=100 api >&2
exit 1
fi
# 3. Switch NGINX to the new colour
echo "server 127.0.0.1:$PORT;" | sudo tee /etc/nginx/erp-upstream.conf >/dev/null
sudo nginx -t
sudo systemctl reload nginx
echo "$NEW" > active_colour
# 4. Give the old colour time to drain, then stop it
sleep 20
docker compose -p "erp-$ACTIVE" stop apiIf the new version never becomes healthy, the script exits before touching NGINX, so users never see it. Because the old colour is stopped rather than removed, rollback is starting it again (docker compose -p erp-blue start api), switching the upstream file back, reloading NGINX and updating active_colour. That takes seconds.
The same pattern works without containers: two systemd template instances, such as app@8001 and app@8002, behind the same upstream file.
With Traefik, the Docker provider ignores containers that are not yet healthy, so you start a second container with the same router labels, wait for it to turn healthy, then stop the old one. The community docker-rollout plugin automates that for Compose, provided the service has no container_name and no fixed host port.
Health checks and graceful shutdown The switch is only as good as the health check behind it. A /health endpoint that returns 200 as soon as the framework loads proves very little. Ours check that the app can reach its database and other hard dependencies with a short timeout, and return 503 otherwise. Keep it cheap: it runs every few seconds.
The other half is shutting down cleanly. When the old colour stops, Docker sends SIGTERM to PID 1 in the container and waits for stop_grace_period before killing it. Three things must be true:
The app receives the signal. Use the exec form of CMD, such as CMD ["node", "server.js"]. The shell form runs your app under /bin/sh, which may not pass SIGTERM on. init: true adds a tiny init as PID 1 that forwards signals and reaps zombie processes. The app handles SIGTERM by refusing new connections and finishing open ones. Gunicorn and Uvicorn do this by default; a plain Node HTTP server needs a handler. The grace period is longer than your slowest normal request. The 10-second default is too short for report exports or large uploads. // Node: finish in-flight requests, then exit
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 25000).unref();
});Queue workers need the same care: on SIGTERM, stop taking new jobs and finish or requeue the current one. Keep job payloads compatible across versions.
Database migrations that do not break the old version For a short window both versions run against the same database, and after a rollback the old version runs against the new schema for much longer. Every migration must be safe for both. The usual approach is expand and contract:
Expand: add new columns and tables in a way old code ignores, such as a nullable column. Deploy code that writes to both old and new structures. Backfill existing rows in small batches, not one huge UPDATE that locks the table. Switch reads to the new structure in a later deploy. Contract: drop the old column only once no running version uses it, often a week or two later. Renaming a column in one step is the classic mistake. So is building an index on a large PostgreSQL table without CONCURRENTLY, which blocks writes for the whole build. A lock timeout makes a blocked change fail fast instead of queueing other queries behind it:
SET lock_timeout = '5s';
ALTER TABLE invoices ADD COLUMN due_on date;
-- run outside a transaction block
CREATE INDEX CONCURRENTLY idx_invoices_due_on ON invoices (due_on);If a concurrent build fails, it leaves an INVALID index behind; drop it and retry. Run migrations as their own CI step before the switch, not at app startup, so two colours never race to apply the same change.
Frontends, sessions and other loose ends Keep the previous build's hashed asset files for at least a day. Users with the old page open will still request them. Store sessions in Redis or the database, or use signed tokens, so logins survive the old process. Send uploads to shared storage such as an S3-compatible bucket, not the container filesystem. Run scheduled jobs in exactly one place, or both colours will run the 2am report. Tag images with the commit SHA, not latest, so you know what is running and what to roll back to. A checklist before you rely on it Run a load loop against the site during a test deploy, for example a curl every 100 ms, and count non-200 responses. The target is zero. Deploy a version whose health check fails on purpose and confirm traffic never moves. Practise a rollback and time it. Check that SIGTERM reaches your app: docker compose stop should finish well within the grace period, not hit it. Review every migration for old-version compatibility before merge. Watch error rates for ten minutes after the switch, with alerts going to someone who can act. On more than one server, deploy one server at a time behind the load balancer with the same script. If deploys still hurt, check migrations and shutdown handling first. The proxy switch is rarely the part that fails.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789