How to buildOctober 11, 202610 min read
Docker and deploymentStep 7 of 8

Deployment: VPS, PaaS or containers, and zero-downtime releases

In short

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.

ComparisonLevel: IntermediateDeployment

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

Verified with

Nginx 1.30.5 (stable)Node.js 24 LTSDocker Engine 29.6

Checked against docs: October 11, 2026

Contents
  1. 01What “deploying” means
  2. 02Three models: VPS, PaaS and container orchestration
  3. 03Zero-downtime releases: rolling, blue-green, canary
  4. 04Demo: blue-green behind Nginx on a single server
  5. 05Database migrations: expand first, contract later
  6. 06Environment variables and secrets
  7. 07Rollback and a release checklist

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.

BuildRelease: build + configStart next to the old oneHealth checkTraffic to the new one
Prepare the releaseRoll out with no downtime
Deployment stages: until the new version passes its check, the old one keeps serving users.

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.

  1. 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.

  2. Switch

    The load balancer or proxy starts sending new requests to the new version. In Nginx that means editing the upstream and doing a graceful reload: old worker processes finish their connections.

  3. Graceful shutdown

    The old version receives SIGTERM, stops accepting connections, finishes in-flight requests and exits. docker stop waits 10 seconds by default before sending SIGKILL; 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.

app/server.mjs
// 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);
Fragment: a simulated warm-up before listen — the rest of the app is as in the Nginx guide
nginx/conf.d/site.conf
include /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;
    }
}
Nginx takes the active version’s address from upstream.inc — the file the deploy rewrites
setup.sh
#!/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
The stand: a network, app v1 (“blue”) and Nginx on port 8081
teardown.sh
#!/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"
Tearing down the stand: stop the containers (--rm removes them) and delete the network
watch.sh
#!/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 -c
Simulated users: a summary by status code and version; 000 means no response within 2 seconds

First, the naive way: stop the running version and start the new one in its place.

naive.sh
#!/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"
Anti-pattern: the service is down between the stop and the new version being ready
terminal
$ ./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 -
53 of 221 requests got a 502: while the new version was starting and warming up, nobody was there to answer

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.

deploy.sh
#!/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"
Blue-green deploy: health check, config validation, graceful Nginx reload, stopping the old version
terminal
$ ./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
Not a single error in 260 requests, and a long request that started on v1 before the switch finished with a 200
terminal
$ 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 v2
Rollback is the same deploy with the previous version: again with no errors

Database 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.

  1. Expandrelease 1

    Add the new thing without removing anything: a new column that allows NULL or has a default, a new table.

  2. Dual writerelease 2

    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.

  3. Switch readsrelease 3

    The code reads from the new field. Rolling back to release 2 is safe: the old field is still being written.

  4. Contractrelease 4

    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 .env to .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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.