Zero-downtime deployments on a single VPS with nginx and Docker
Deploy without dropped requests on one VPS: blue-green Docker Compose services, an nginx upstream switch, graceful SIGTERM handling and safe migrations.
Updated 9 min read

In this article
You do not need Kubernetes to deploy without downtime. On a single VPS, run two copies of your app side by side (blue and green), start the new version on the idle copy, wait until it reports ready, then point nginx at it with a graceful reload. nginx finishes in-flight requests on the old copy while new requests go to the new one, and rollback is one command.
This guide builds that setup with Docker Compose, nginx and a short Bash script, and covers the two things that usually still cause errors during a "zero-downtime" deploy: apps that die mid-request on SIGTERM, and database migrations that break the version still serving traffic.

Why simple deploys drop requests
The usual small-team deploy is git pull, build, restart. For the seconds between the old process stopping and the new one listening, nginx has nowhere to send requests and returns 502 errors. Even a fast restart cuts off requests that were in progress, and a new version that crashes on start-up leaves the site down until someone notices.
Blue-green deployment fixes all three problems by never stopping the version that is serving traffic until its replacement is proven healthy.
| Approach | Downtime | Rollback | Extra resources |
|---|---|---|---|
| Stop, then start | Seconds to minutes, plus cut-off requests | Redeploy the old build | None |
| Process manager cluster reload | None if every worker shuts down cleanly | Redeploy the old build | None |
| Blue-green behind nginx (this guide) | None | Switch back to the old colour | Memory for a second copy during deploys |
| Rolling update on an orchestrator | None | Built in | A cluster to run and maintain |
How nginx makes the switch safe
The trick relies on documented nginx behaviour. When you run systemctl reload nginx, the master process first checks the new configuration. If it is invalid, nginx keeps running with the old one. If it is valid, nginx starts new worker processes and asks the old workers to shut down gracefully: they stop accepting connections but "continue to service old clients" until those requests complete.
So changing which upstream nginx points at and reloading is atomic from a visitor's point of view: no request is refused, and no request in progress is cut off by nginx.
Step 1: Two copies of the app with Docker Compose
Define the same image twice, published only on localhost, on two different ports:
services:
app_blue:
image: app:blue
env_file: .env.production
ports:
- "127.0.0.1:3001:3000"
init: true
stop_grace_period: 30s
restart: unless-stopped
app_green:
image: app:green
env_file: .env.production
ports:
- "127.0.0.1:3002:3000"
init: true
stop_grace_period: 30s
restart: unless-stopped- Ports bound to
127.0.0.1: Docker's documentation warns that publishing a port without a host IP binds to all interfaces and bypasses host firewall rules. Only nginx should reach these ports. stop_grace_period: 30s: Compose waits 10 seconds by default before sendingSIGKILL. Thirty seconds gives long requests time to finish.init: true: runs a small init process as PID 1 that forwards signals to your app and reaps zombie processes.
Step 2: Let nginx include a swappable upstream
Create one upstream file per colour, and a symlink that says which one is live. The site configuration includes the symlink:
upstream app_backend {
server 127.0.0.1:3001;
keepalive 16;
}The green file is identical except for port 3002. Install both and point the symlink at blue:
sudo mkdir -p /etc/nginx/upstreams
sudo cp blue.conf green.conf /etc/nginx/upstreams/
sudo ln -sfn /etc/nginx/upstreams/blue.conf /etc/nginx/app-upstream.conf
echo blue | sudo tee /srv/app/active-colourThen reference app_backend in your site file:
include /etc/nginx/app-upstream.conf;
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app_backend;
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;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}
}Clearing the Connection header and using HTTP/1.1 lets nginx reuse keep-alive connections to the app, as the nginx upstream documentation requires. The proxy_next_upstream lines are a safety net for when you later list more than one server in an upstream: nginx only retries on another server if nothing has been sent to the client yet. In production, add the TLS settings from your existing HTTPS server block.

Step 3: Make the app shut down cleanly
A deploy is only as graceful as the process being stopped. When Docker stops a container it sends SIGTERM, waits for the grace period, then sends SIGKILL. Your app should stop accepting new work, finish what it has, close database pools and exit. It should also expose a readiness endpoint so the deploy script knows when a new copy can take traffic.
import { createServer } from 'node:http';
const handle = (req, res) => { res.end('hello'); }; // replace with your app's handler
let draining = false;
const server = createServer((req, res) => {
if (req.url === '/readyz') {
res.writeHead(draining ? 503 : 200, { 'content-type': 'text/plain' });
res.end(draining ? 'draining' : 'ok');
return;
}
handle(req, res);
});
server.listen(3000);
process.on('SIGTERM', () => {
draining = true;
// Stop accepting new connections; in-flight requests finish.
// Since Node.js 19, close() also closes idle keep-alive connections.
server.close(async () => {
// await db.end(); await redis.quit(); // close pools here
process.exit(0);
});
// Hard stop shortly before Docker's 30s grace period ends.
setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 25_000).unref();
});Since Node.js 19, server.close() stops accepting new connections and also closes idle keep-alive connections, so only real in-flight requests keep the process alive. closeAllConnections() is the documented forceful fallback; Node's documentation recommends calling it after close(). Express, Fastify and NestJS apps can use the same pattern, because they all sit on a Node.js HTTP server.
Make /readyz check what the app needs to serve real requests (for example, a quick database ping), not just that the process is up.
Step 4: The deploy script
#!/usr/bin/env bash
# Usage: ./deploy.sh registry.example.com/app:2026-10-10.1
set -euo pipefail
IMAGE="${1:?usage: deploy.sh <image>}"
APP_DIR=/srv/app
STATE_FILE="$APP_DIR/active-colour"
current=$(cat "$STATE_FILE" 2>/dev/null || echo green)
if [ "$current" = "blue" ]; then next=green; port=3002; else next=blue; port=3001; fi
echo "Live: $current. Deploying $IMAGE as $next on port $port"
cd "$APP_DIR"
docker pull "$IMAGE"
docker tag "$IMAGE" "app:$next"
docker compose up -d --no-deps --force-recreate "app_$next"
# Wait up to 60 seconds for the new colour to report ready.
for attempt in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$port/readyz" > /dev/null; then
break
fi
if [ "$attempt" -eq 30 ]; then
echo "New version never became ready; live traffic untouched." >&2
docker compose logs --tail 50 "app_$next" >&2
docker compose stop "app_$next"
exit 1
fi
sleep 2
done
# Point nginx at the new colour. A failed config test aborts before reload.
sudo ln -sfn "/etc/nginx/upstreams/$next.conf" /etc/nginx/app-upstream.conf
sudo nginx -t
sudo systemctl reload nginx
echo "$next" > "$STATE_FILE"
# Give in-flight requests time to finish on the old colour, then stop it.
sleep 15
docker compose stop "app_$current"
echo "Done: $next is live. Roll back with: ./switch.sh $current"The script never touches the live colour until the new one passes its readiness check. If the new version fails to start, the script prints its logs, stops it and exits, and visitors never see it. After the switch, the old colour is kept running for 15 seconds so requests already routed to it can finish, then stopped with its 30-second grace period.
Rolling back
Because the previous image is still tagged locally as the other colour, rollback is a start-and-switch:
#!/usr/bin/env bash
# Usage: ./switch.sh blue|green (rollback to the other colour)
set -euo pipefail
colour="${1:?usage: switch.sh blue|green}"
if [ "$colour" = "blue" ]; then port=3001; else port=3002; fi
cd /srv/app
docker compose start "app_$colour"
until curl -fsS "http://127.0.0.1:$port/readyz" > /dev/null; do sleep 2; done
sudo ln -sfn "/etc/nginx/upstreams/$colour.conf" /etc/nginx/app-upstream.conf
sudo nginx -t
sudo systemctl reload nginx
echo "$colour" > /srv/app/active-colourStep 5: Database migrations without breaking the live version
For a short time both versions run at once, and the old one keeps serving traffic until the switch. A migration that renames or drops a column the old code still reads will break the live site before the new version is even ready. Use the expand-and-contract pattern instead:
- Expand: add new columns or tables in a backwards-compatible way (nullable or with defaults). Deploy code that writes to both old and new fields and reads from the old.
- Migrate: backfill existing rows in batches, outside the deploy if it takes more than a few seconds.
- Switch: deploy code that reads from the new field.
- Contract: once no running version uses the old column, drop it in a later release.
Run migrations as a separate step before the deploy script, never on container start-up in both colours. Watch for statements that lock large tables; on MySQL and PostgreSQL, adding an index or changing a column type on a big table can block writes for longer than any restart would have.
Gotchas
- Memory: during a deploy both copies run. Make sure the VPS has headroom for two app processes plus the database.
- Sessions and uploads: anything kept in process memory or on the container's local disk is lost on switch. Keep sessions in Redis or the database and files in object storage.
- Background workers: run queue workers as their own service, not inside both web colours, or jobs will be processed twice as fast as you planned, by two code versions.
- WebSockets: long-lived connections stay on the old colour until they close. Clients must reconnect, so build reconnection with back-off into the front end.
- Health checks that lie: a
/readyzthat always returns 200 turns this into an ordinary risky deploy. Test that it fails when the database is unreachable. - The symlink is changed before
nginx -t: if the test fails, the next reload would pick up the broken file. The script stops on the failure; fix the file or point the link back before doing anything else.
What to do next
- Add a
/readyzendpoint and theSIGTERMhandler to your app first; they help with any hosting model. - Run the deploy script from CI over SSH with a dedicated deploy user that may only run the Docker and nginx commands it needs.
- Time a deploy while a load-testing tool sends steady traffic, and check the nginx access log for any 502 responses.
- When one VPS is no longer enough, the same image, readiness endpoint and shutdown handling move unchanged to a managed container platform or Kubernetes.
Frequently asked questions
Can I do zero-downtime deploys without Docker?
Yes. Run two copies of the app as systemd services on different ports, or use a process manager's cluster reload. The essentials are the same: a readiness check before switching, a graceful nginx reload, and an app that finishes in-flight requests on
SIGTERM.Does nginx reload drop connections?
No. On reload, nginx validates the new configuration, starts new workers, and asks old workers to shut down gracefully. Old workers stop accepting new connections but keep serving existing clients until they finish. If the configuration is invalid, nginx keeps running with the old one.
How long should the stop grace period be?
Longer than your slowest normal request. Docker waits 10 seconds by default before sending
SIGKILL; this guide usesstop_grace_period: 30sand forces the app to exit after 25 seconds. Move anything slower than that into a background job.What is the difference between a health check and a readiness check?
A health (liveness) check answers “is the process alive?” A readiness check answers “can it serve real traffic right now?”, which usually means its database and cache connections work. Only switch traffic when readiness passes, and return 503 from readiness while draining.
How do I handle database migrations during a blue-green deploy?
Make every migration compatible with both the old and new code: add columns before using them, and drop old columns only in a later release. Run migrations as a separate step before switching traffic, and avoid long table locks during busy hours.
When should I move to Kubernetes or a managed platform instead?
When one server can no longer handle the load, when you need redundancy across machines, or when several services need consistent deployment. The container image, readiness endpoint and graceful shutdown you build here carry over unchanged.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Controlling nginx — nginx.org
- Module ngx_http_upstream_module — nginx.org
- Module ngx_http_proxy_module — nginx.org
- HTTP | Node.js v24 documentation — OpenJS Foundation
- Services top-level elements (Compose file reference) — Docker
- docker container stop — Docker
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Interview preparationInterview questions
Docker interview questions and answers for Docker Engine 29
18 Docker interview questions from images and volumes to build cache, Compose, networking, signals and the containerd image store, with short model answers.
By Cloudsoft Zone

Resource guideResources
Cron syntax cheat sheet: crontab, GitHub Actions, Kubernetes
Cron syntax with tested expressions, the day-of-week trap, and how time zones and limits differ in crontab, GitHub Actions, Kubernetes and Laravel in 2026.
By Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone
