A VPS gives you full control of the server, but updates, HTTPS, monitoring and zero-downtime releases are on you; a PaaS such as Vercel, Render or Fly.io takes that over in exchange for the platform’s constraints; Kubernetes pays off when there are many services and instances to orchestrate automatically. Zero-downtime releases work the same way in every model: the new version starts next to the old one, passes a health check, receives traffic, and the old one gracefully finishes its in-flight requests. Database migrations are kept backward compatible so that rolling back the code stays possible.
After this you will be able to
- Compare VPS, PaaS and container orchestration by control, responsibility and effort, and pick a model for a project
- Describe rolling and blue-green releases with a health check and graceful shutdown of the old version
- Plan a database migration in the expand/contract style so that a code rollback doesn’t break data
- Build a release checklist covering environment variables and a rollback plan
Know beforehand
Verified with
Nginx 1.30.5 (stable)Node.js 24 LTSDocker Engine 29.6
Checked against docs: October 11, 2026
Contents
What “deploying” means
Deploying is not “copying files to a server” but a chain of three stages that The Twelve-Factor App calls build, release and run. The build turns code into a runnable artifact such as a bundle or a Docker image. The release combines the build with the configuration of a specific environment. The run stage starts the release’s processes and routes traffic to them.
Configuration that differs between environments — database addresses, third-party keys, the domain — is, by the same methodology, stored in environment variables rather than in code. The same image goes to staging and production; only the variables change.
Three models: VPS, PaaS and container orchestration
VPS
- What the platform provides
- A virtual server with root access
- What stays with the team
- OS and updates, firewall, Nginx and HTTPS, backups, monitoring, the rollout script
- When it fits
- Full control, non-standard software, long-running processes
PaaS: Vercel, Render, Fly.io
- What the platform provides
- Builds from Git, HTTPS, rollout, rollback, logs
- What stays with the team
- Code, environment variables, migrations, fitting into platform limits
- When it fits
- A small team, a standard stack, frequent releases
Containers and Kubernetes
- What the platform provides
- Declarative deployments, rolling updates, health checks, revision rollback
- What stays with the team
- A cluster or managed service, manifests, networking, observability
- When it fits
- Many services and instances, and an engineer who owns the platform
A VPS means full control and full responsibility. The server is yours: install any software and keep WebSocket connections or background processes running as long as you like. But zero-downtime releases, certificate renewals, security patches and recovering the server after a failure are also yours. A typical stack is Docker with Nginx in front of the app, as in the reverse proxy guide.
A PaaS (Platform as a Service) takes over operations but imposes its own model. On Vercel every deployment is a separate build with its own URL, and Instant Rollback points production back to an earlier deployment. Render starts a new instance on every deploy, switches traffic and sends the old one SIGTERM. Fly.io lets you choose a rollout strategy in fly.toml. Limits on execution time, the file system and background work differ per platform — read them before choosing, not after.
Kubernetes solves orchestration: it keeps the desired number of instances running, restarts failed ones, rolls out a new version gradually and keeps a revision history for rollback. It’s powerful but heavy. In our experience, one or two services with a single database usually do fine on a VPS with Docker Compose or on a PaaS; teams move to Kubernetes when there are many services and someone owns the platform.
Choose a PaaS if
- The team is small and doesn’t want to run servers
- The platform supports your stack out of the box
- You want preview environments for every branch
- The platform’s limits don’t get in the way
Choose a VPS or containers if
- You need non-standard software or OS access
- You have long-running processes, persistent connections or local disk
- Requirements say data must live in a specific country or with a specific provider
- You have the skills and time for operations
Zero-downtime releases: rolling, blue-green, canary
Recreate
- How it works
- Stop all old instances, then start the new ones
- Pros
- Simple; two versions never run at once
- Cons
- Downtime while the new version starts
Rolling
- How it works
- Replace instances one by one or in batches
- Pros
- No need for double capacity
- Cons
- Both versions run side by side for a while
Blue-green
- How it works
- Start a full copy of the new version and switch all traffic at once
- Pros
- Instant switch and an equally fast rollback
- Cons
- Needs capacity for two versions during the release
Canary
- How it works
- One instance of the new version first, then the rest
- Pros
- A bug affects a small share of requests
- Cons
- You need metrics to notice the problem
A Kubernetes Deployment uses the RollingUpdate strategy by default: maxUnavailable and maxSurge both default to 25%, so during a rollout at least 75% of the desired pods are available and at most 125% are running. The alternative is Recreate. Fly.io rolls out with rolling by default and also supports immediate, canary and bluegreen; the last two don’t work with Machines that have volumes attached, and bluegreen requires at least one health check. For a multi-instance service, Render replaces instances one at a time and cancels the whole deploy if a new instance fails to become healthy.
Health check
The new version gets traffic only after a check. In Kubernetes this is a readiness probe: a pod that isn’t ready is removed from load balancing. A liveness probe answers a different question — when to restart a container — and the docs warn that a careless liveness check can cause cascading failures.
Switch
The load balancer or proxy starts sending new requests to the new version. In Nginx that means editing the
upstreamand doing a graceful reload: old worker processes finish their connections.Graceful shutdown
The old version receives
SIGTERM, stops accepting connections, finishes in-flight requests and exits.docker stopwaits 10 seconds by default before sendingSIGKILL; Render waits 30 seconds, and the delay can be extended.
Demo: blue-green behind Nginx on a single server
Let’s see the difference in practice. The stand is a Docker network, Nginx 1.30.5 and the Node.js app from the reverse proxy guide, which additionally “warms up” for 3 seconds before opening its port, like a real service connecting to its database. The watch.sh script plays the users: it sends a request roughly every 50 ms and finally counts responses by status code and version. Put the scripts in the same folder as app/ and nginx/ and make them executable with chmod +x *.sh. All output below is real.
// simulated warm-up (DB connections, caches) before the port opens
const STARTUP_DELAY_MS = Number(process.env.STARTUP_DELAY_MS ?? 0);
setTimeout(() => {
server.listen(PORT, () => console.log(`app ${VERSION} on :${PORT}`));
}, STARTUP_DELAY_MS);listen — the rest of the app is as in the Nginx guideinclude /etc/nginx/conf.d/upstream.inc;
server {
listen 80;
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}upstream.inc — the file the deploy rewrites#!/usr/bin/env bash
# Local stand: one Docker network, the app v1 ("blue") and Nginx in front of it
set -euo pipefail
P="${PREFIX:-demo}"
docker network create "$P-net" >/dev/null
echo "upstream app_backend { server $P-app-blue:3000; }" > nginx/conf.d/upstream.inc
docker run -d --rm --name "$P-app-blue" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION=v1 -v "$PWD/app:/app:ro" node:24-alpine node /app/server.mjs >/dev/null
docker run -d --rm --name "$P-proxy" --network "$P-net" -p 8081:80 \
-v "$PWD/nginx/conf.d:/etc/nginx/conf.d:ro" nginx:stable-alpine >/dev/null
echo blue > .active
sleep 5 && curl -s http://localhost:8081/api/whoami && echo#!/usr/bin/env bash
P="${PREFIX:-demo}"
docker stop "$P-proxy" "$P-app-blue" "$P-app-green" >/dev/null 2>&1
docker network rm "$P-net" >/dev/null 2>&1
rm -f .active nginx/upstream.prev
echo "stand removed"--rm removes them) and delete the network#!/bin/sh
# Simulated users: a request every ~50 ms for N seconds, then a summary
# of status codes and versions ("000" = no response within 2 s).
# Usage: ./watch.sh 20 http://localhost:8081/api/whoami
DURATION="${1:-20}"; URL="${2:-http://localhost:8081/api/whoami}"
END=$(( $(date +%s) + DURATION ))
while [ "$(date +%s)" -lt "$END" ]; do
out=$(curl -s -m 2 -w ' %{http_code}' "$URL")
code="${out##* }"
version=$(printf '%s' "$out" | grep -o '"version":"[^"]*"' | cut -d'"' -f4)
echo "$code ${version:--}"
sleep 0.05
done | sort | uniq -c000 means no response within 2 secondsFirst, the naive way: stop the running version and start the new one in its place.
#!/usr/bin/env bash
# Anti-pattern: stop the running version, then start the new one in its place
set -euo pipefail
P="${PREFIX:-demo}"
docker stop "$P-app-blue" >/dev/null
docker run -d --rm --name "$P-app-blue" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION="$1" -v "$PWD/app:/app:ro" node:24-alpine node /app/server.mjs >/dev/null
echo "restarted in place with $1"$ ./setup.sh
{"version":"v1","host":"localhost","forwardedFor":"192.168.16.1","peer":"::ffff:192.168.16.3"}
$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 15 http://demo-proxy/api/whoami > naive.txt &
$ sleep 3; ./naive.sh v2; wait
restarted in place with v2
$ cat naive.txt
49 200 v1
119 200 v2
53 502 -Now blue-green: the new version starts alongside, passes /healthz, Nginx switches with a graceful reload, and only then does the old version get SIGTERM. If the check or nginx -t fails, traffic stays on the old version.
#!/usr/bin/env bash
# Blue-green release behind Nginx: start the new version next to the old one,
# wait for its health check, switch Nginx with a graceful reload, stop the old one.
# Usage: ./deploy.sh v2
set -euo pipefail
VERSION="$1"
P="${PREFIX:-demo}" # container name prefix
ACTIVE=$(cat .active 2>/dev/null || echo blue)
NEXT=$([ "$ACTIVE" = blue ] && echo green || echo blue)
echo "deploy $VERSION: $ACTIVE -> $NEXT"
docker run -d --rm --name "$P-app-$NEXT" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION="$VERSION" -v "$PWD/app:/app:ro" \
node:24-alpine node /app/server.mjs >/dev/null
# 1. Health check: the new version must answer before it gets traffic
for i in $(seq 1 20); do
if docker exec "$P-proxy" curl -fsS "http://$P-app-$NEXT:3000/healthz" >/dev/null 2>&1; then
echo "health check passed after $i attempt(s)"
break
fi
if [ "$i" -eq 20 ]; then
echo "health check failed: $NEXT stays out of traffic"
docker stop "$P-app-$NEXT" >/dev/null
exit 1
fi
sleep 0.5
done
# 2. Switch: rewrite the upstream, validate the config, reload gracefully
cp nginx/conf.d/upstream.inc nginx/upstream.prev
echo "upstream app_backend { server $P-app-$NEXT:3000; }" > nginx/conf.d/upstream.inc
if ! docker exec "$P-proxy" nginx -t >/dev/null 2>&1; then
cp nginx/upstream.prev nginx/conf.d/upstream.inc
docker stop "$P-app-$NEXT" >/dev/null
echo "nginx -t failed: config restored"
exit 1
fi
docker exec "$P-proxy" nginx -s reload
echo "$NEXT" > .active
# 3. Old version: SIGTERM, it finishes in-flight requests and exits
sleep 2
docker stop "$P-app-$ACTIVE" >/dev/null
echo "done: $VERSION is live on $NEXT, $ACTIVE stopped"$ ./teardown.sh && ./setup.sh
stand removed
{"version":"v1","host":"localhost","forwardedFor":"192.168.16.1","peer":"::ffff:192.168.16.3"}
$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 20 http://demo-proxy/api/whoami > bg.txt &
$ sleep 3
$ docker run --rm --network demo-net nginx:stable-alpine curl -s -w ' %{http_code}\n' "http://demo-proxy/api/slow?ms=8000" > slow.txt &
$ sleep 0.5; ./deploy.sh v2; wait
deploy v2: blue -> green
health check passed after 4 attempt(s)
2026/10/12 01:36:08 [notice] 77#77: signal process started
done: v2 is live on green, blue stopped
$ cat bg.txt slow.txt
126 200 v1
134 200 v2
{"waitedMs":8000} 200$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 15 http://demo-proxy/api/whoami > rollback.txt &
$ sleep 3; ./deploy.sh v1; wait
deploy v1: green -> blue
health check passed after 5 attempt(s)
2026/10/12 01:36:29 [notice] 149#149: signal process started
done: v1 is live on blue, green stopped
$ cat rollback.txt
108 200 v1
126 200 v2Database migrations: expand first, contract later
With rolling and blue-green releases, the old and new code run against the same database for a while. If a migration renames a column, the old version breaks, and rolling back the code won’t help: the schema has already changed. Martin Fowler describes the way out as parallel change, or expand and contract: the change is split into phases, and at every phase the schema works with both versions of the code.
Expand
Add the new thing without removing anything: a new column that allows
NULLor has a default, a new table.Dual write
The code writes to both the old and new field and still reads the old one. Existing rows are filled in by a background data migration.
Switch reads
The code reads from the new field. Rolling back to release 2 is safe: the old field is still being written.
Contract
Once nothing uses the old field, drop it in a separate migration.
Where to run migrations also depends on the platform. Render has a pre-deploy command for this: it runs after the build on a separate instance before the rollout, and if it fails the deploy doesn’t happen and the service keeps running the previous version; it’s available on paid services. Fly.io has release_command, which runs in a temporary Machine with the new image: a non-zero exit code cancels the deploy, and the default timeout is 5 minutes. On a VPS the migration is a separate CI step before traffic is switched.
Environment variables and secrets
Twelve-Factor offers a simple test: could you open-source the repository right now without exposing a single credential? If not, configuration is mixed with code. Keys and addresses are injected at start-up through environment variables or the platform’s secret store and never end up in Git or in the Docker image.
- On Vercel, changes to environment variables apply only to new deployments, and Instant Rollback restores the earlier build without the new values — after rotating a key you need a new deployment
- Vercel encrypts variable values at rest, and secret values become write-only once saved
- On a VPS, pass variables through an environment file with restricted permissions or a secrets manager; add
.envto.gitignore - Each environment has its own keys: production keys are not used in staging or on developer laptops
Rollback and a release checklist
A rollback is a rehearsed action, not an improvisation during an incident. In Kubernetes it’s kubectl rollout undo; a Deployment keeps 10 old revisions by default, and with revisionHistoryLimit: 0 there’s nothing to roll back to. On Vercel it’s Instant Rollback, and on the Hobby plan only to the immediately previous deployment. On a VPS it’s redeploying the previous image tag, as in the demo.
- The build is immutable and has an ID — an image tag or deployment ID, not “whatever is latest on main”
- Database migrations are backward compatible and run before traffic is switched
- New environment variables and secrets are added to every environment that needs them
- The health check tests real readiness, including the database connection
- The app handles
SIGTERM: it stops accepting requests and finishes the current ones - You know which command performs a rollback and in how many steps, and it has been tested at least once
- After the switch, watch errors and response times, not just the green deploy status
- The old version stays alive until checks are done, so a rollback is instant
Zero-downtime release is not a hosting feature but an order of operations: start alongside, check, switch, stop gracefully.
How this was verified2 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.