DevOps Advanced

Zero-Downtime Deploys for Django on a Single VPS: Gunicorn Graceful Reloads, Release Directories and Health Checks

Stop dropping requests on deploy: systemd socket activation, gunicorn HUP reloads, atomic release symlinks, expand/contract migrations and a /healthz-gated deploy script that rolls itself back, all on one Ubuntu 24.04 VPS.

DjangoZen Team Sep 27, 2026 25 min read 2 views

On a single VPS, most Django deploys still end with git pull && systemctl restart gunicorn, and every one of them throws a handful of 502s at real users. You do not need Kubernetes or a load balancer to fix that. With a systemd-held socket, gunicorn's graceful reload, immutable release directories, backward-compatible migrations and a health check that can trigger an automatic rollback, you get deploys nobody notices.

Why restart drops requests

systemctl restart is a stop followed by a start. Gunicorn handles SIGTERM gracefully and lets workers finish their current request. The requests you lose are the ones around it:

  • The listening socket closes with the master. Connections that were accepted by the kernel but not yet picked up by a worker sit in the backlog. They are reset when the socket closes.
  • There is a gap. Nothing is listening until the new master has bound the socket. Everything nginx sends during that time gets connect() failed (111: Connection refused), which the user sees as a 502. A Django app with a large URLconf can take several seconds to import.
  • Long requests are cut off. Anything still running when systemd's stop timeout expires is killed with SIGKILL.

The fix has two parts. First, the socket has to outlive the process. Second, new workers have to start before the old ones leave.

Let systemd own the socket

With socket activation, systemd creates /run/gunicorn.sock and passes the file descriptor to gunicorn through LISTEN_FDS. Gunicorn detects this and uses the inherited socket. You do not set bind. When the service restarts, the socket stays open and new connections queue in the kernel backlog instead of being refused. At worst a restart becomes a latency spike rather than a set of errors.

# /etc/systemd/system/gunicorn.socket
[Unit]
Description=gunicorn socket for myproject

[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketGroup=deploy
SocketMode=0660

[Install]
WantedBy=sockets.target
# /etc/systemd/system/gunicorn.service
[Unit]
Description=gunicorn for myproject
Requires=gunicorn.socket
After=network.target postgresql.service

[Service]
Type=notify
NotifyAccess=main
User=deploy
Group=deploy
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/env
ExecStart=/srv/app/current/venv/bin/gunicorn --config /srv/app/shared/gunicorn.conf.py myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=45
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

KillMode=mixed sends SIGTERM only to the master, which then drains its own workers. Remaining processes get SIGKILL only after TimeoutStopSec. SocketGroup=deploy lets the deploy script run health checks directly against the socket. Type=notify makes systemctl restart wait until gunicorn reports it is ready, so anything that runs after it sees a live service. Service and socket names must match: gunicorn.socket activates gunicorn.service. The bootstrap steps below cover enabling them.

# /srv/app/shared/gunicorn.conf.py
import multiprocessing

chdir = "/srv/app/current"        # re-resolved by every new worker
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
preload_app = False               # required for HUP to load new code
timeout = 30
graceful_timeout = 25
accesslog = "-"
errorlog = "-"
# /etc/nginx/sites-available/myproject (excerpt)
upstream myproject {
    server unix:/run/gunicorn.sock fail_timeout=0;
}

server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate     /etc/ssl/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/ssl/app.example.com/privkey.pem;

    location /static/ {
        alias /srv/app/shared/static/;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location / {
        proxy_pass http://myproject;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

What HUP actually does

systemctl reload gunicorn runs ExecReload, which sends SIGHUP to the master. The master re-reads its config, forks a fresh set of workers and tells the old workers to shut down gracefully. The master and the listening socket are never replaced, so no connection is refused. There is one small window: old workers stop accepting as soon as the new ones are forked, before the new ones have finished importing Django. Requests that arrive during those seconds wait in the backlog. They are not dropped.

Three details decide whether HUP deploys your new code.

  • preload_app must be off. With preload_app = True the master imports the application once and forks workers from it. HUP then forks new workers from the old code in memory. Preloading saves memory through copy-on-write, but it is incompatible with reload-based deploys.
  • Set chdir explicitly. Each new worker calls os.chdir("/srv/app/current") and imports from that path. Because the symlink is resolved again at that point, the worker picks up the new release. If you leave chdir unset, it defaults to the master's working directory, which is the old release it started in.
  • The interpreter is pinned. The master's Python and its sys.path, including site-packages, come from the venv it was started from. Workers inherit them. HUP loads new application code, not new dependencies. If the lockfile changed, you need a real restart. With socket activation that is safe. You also must not delete the pinned release while the master is still running from it.
SettingGovernsGuidance
timeoutHow long a worker can be silent (for sync workers, one request) before the master kills and replaces itYour slowest legitimate request plus margin
graceful_timeoutHow long a worker told to stop (by HUP, TERM or a scale-down) has to finish its current request before SIGKILLAt least your p99 request time
TimeoutStopSec (systemd)How long systemd waits for the whole service to stopGreater than graceful_timeout, or systemd cuts the drain short

Release directories and the atomic switch

/srv/app/
├── current -> /srv/app/releases/20260927T101500Z
├── releases/
│   ├── 20260926T163012Z/   (manage.py, myproject/, venv/, requirements.txt)
│   └── 20260927T101500Z/
└── shared/
    ├── env                 # KEY=value lines, read by systemd and bash
    ├── gunicorn.conf.py
    ├── master-release      # release the running master was started from
    └── static/             # STATIC_ROOT, shared across releases

A release is never modified after it goes live. Each one gets its own venv, created in place. Venvs are not relocatable, because the shebang lines contain absolute paths. Build and test everything next to the live release, then switch in one step:

ln -sfn /srv/app/releases/20260927T101500Z /srv/app/current.tmp
mv -T /srv/app/current.tmp /srv/app/current

ln -sfn on its own is an unlink followed by a symlink, so for a moment current does not exist. mv -T is a single rename(2), which is atomic. The -T flag stops mv from treating the destination as a directory and moving the temp link into the old release.

Anything that resolves paths through current sees either the old release or the new one, never a half-copied mix. Compare that with git pull or rsync into a live directory: for a few seconds a worker that respawns (through max_requests, a crash or the timeout killer) can import a new models.py next to an old views.py. Nobody can reproduce the errors that come out of that window afterwards.

Moving an existing server to this layout

The migration costs one ordinary restart, so do it during a quiet period. Create the directories, move the environment file, install the units and enable the socket. Do not start the service yet, because current does not exist until the first deploy creates it:

sudo install -d -o deploy -g deploy /srv/app/releases /srv/app/shared \
     /srv/app/shared/static /srv/app/bin
sudo install -o deploy -g deploy -m 0600 /path/to/old/env /srv/app/shared/env
sudo systemctl stop gunicorn.service            # the last non-graceful stop
sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn.socket
sudo systemctl enable gunicorn.service
sudo -u deploy /srv/app/bin/deploy.sh /tmp/myproject-build.tar.gz
sudo nginx -t && sudo systemctl reload nginx     # after switching the upstream

On that first run there is no pinned release, so the script uses restart, which starts the service. There is also no previous release, so a failed health check exits without trying to roll back. Every deploy after that follows the graceful path.

collectstatic before the switch

# myproject/settings.py
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent   # resolves the symlink
RELEASE_ID = BASE_DIR.name                          # e.g. 20260927T101500Z

STATIC_URL = "static/"
STATIC_ROOT = "/srv/app/shared/static"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
}
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

With DEBUG = False, ManifestStaticFilesStorage raises ValueError for any file missing from staticfiles.json. New templates must therefore never run before their manifest exists. Run collectstatic into a shared STATIC_ROOT before the switch. Hashed filenames mean old and new assets live side by side, so a browser holding a page rendered by the old release can still load app.3f9c1a.css. Never pass --clear here.

Order matters in both directions. Run it after the switch and the new templates reference files that do not exist yet. Put STATIC_ROOT inside each release and serve /static/ through current, and the old hashed files disappear the moment you switch, taking the stylesheets of every open tab with them. Hashed assets are small, so the shared directory grows slowly. If you do clean it, do it in a quiet period and run collectstatic for the current release straight afterwards. The manifest storage writes back any hashed file that is still referenced but missing. Do not delete by modification time alone. collectstatic skips unchanged sources, so a file that is still in use can easily be months old.

Expand/contract migrations

Migrations run before the switch, while the old code is still serving. Every migration in a deploy must therefore be compatible with the release before it. That same property is what makes rolling back the code safe. Rollback never reverses migrations.

  • Expand: add nullable columns, or columns with db_default (Django 5.0+), so that the old code's INSERTs still succeed. A plain default= is applied only by Django, and Django drops it from the database afterwards.
  • Contract: stop using a column in release N and drop it in release N+1 or later.
  • Rename: add the new column, write to both, backfill, switch reads, then drop the old one. That takes at least three deploys.
# shop/migrations/0042_order_expand_contract.py  (release N)
from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [("shop", "0041_previous")]

    operations = [
        # expand: old code inserting without this column still works
        migrations.AddField(
            model_name="order",
            name="fulfilment_status",
            field=models.CharField(max_length=20, db_default="pending"),
        ),
        # contract, step 1: forget the field in Django, keep the column
        migrations.SeparateDatabaseAndState(
            database_operations=[
                migrations.RunSQL(
                    "ALTER TABLE shop_order ALTER COLUMN legacy_ref DROP NOT NULL",
                    reverse_sql=migrations.RunSQL.noop,
                ),
            ],
            state_operations=[
                migrations.RemoveField(model_name="order", name="legacy_ref"),
            ],
        ),
    ]

In a later release, once no release you might roll back to still references legacy_ref, a RunSQL("ALTER TABLE shop_order DROP COLUMN legacy_ref") finishes the contraction. The script runs migrate with PGOPTIONS='-c lock_timeout=5s'. If a migration cannot get its lock quickly, it fails. It does not queue behind a long transaction while every request to that table piles up behind it. If a change genuinely cannot be made backward-compatible, schedule a maintenance window.

A cheap /healthz

# myproject/health.py
from django.conf import settings
from django.db import DatabaseError, connection
from django.http import HttpResponse
from django.views.decorators.cache import never_cache


@never_cache
def healthz(request):
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
    except DatabaseError:
        return HttpResponse(f"db-down {settings.RELEASE_ID}", status=503,
                            content_type="text/plain")
    return HttpResponse(f"ok {settings.RELEASE_ID}", content_type="text/plain")

# myproject/urls.py:  path("healthz", healthz),

Returning the release ID is the important part. Because the script polls until it sees ok <new-release>, it knows the new workers are actually serving, not just that some worker answered. The check proves the app imports, routing works and PostgreSQL is reachable. Do not check Redis, the mail server or third-party APIs here. An outage in one of those should not roll back a good deploy.

A few details keep the check honest. It hits the unix socket directly, so it tests gunicorn and Django, not nginx or a cache. The Host header must be in ALLOWED_HOSTS, or Django answers 400. The X-Forwarded-Proto: https header, together with SECURE_PROXY_SSL_HEADER, stops SECURE_SSL_REDIRECT from answering with a 301. The alternative is to add r"^healthz$" to SECURE_REDIRECT_EXEMPT. never_cache keeps any cache layer from holding on to the response. If you would rather not publish release IDs, add a location = /healthz block in nginx with allow and deny rules. The deploy script bypasses nginx, so it is not affected. Keep the view free of sessions and authentication so it stays a single query.

The deploy script

The script runs as deploy and takes a build tarball produced by CI. Allow exactly the service commands it needs in /etc/sudoers.d/deploy: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload gunicorn.service, /usr/bin/systemctl restart gunicorn.service.

#!/usr/bin/env bash
# /srv/app/bin/deploy.sh ARTIFACT.tar.gz
set -Eeuo pipefail

APP=/srv/app
KEEP=5
SERVICE=gunicorn.service
SOCK=/run/gunicorn.sock
HOST=app.example.com
PYTHON=/usr/bin/python3.12
ARTIFACT=${1:?usage: deploy.sh ARTIFACT.tar.gz}

RELEASE_ID=$(date -u +%Y%m%dT%H%M%SZ)
RELEASE=$APP/releases/$RELEASE_ID
PREVIOUS=$(readlink -f "$APP/current" || true)
PINNED_FILE=$APP/shared/master-release

exec 9>"$APP/shared/deploy.lock"
flock -n 9 || { echo "deploy already running" >&2; exit 1; }

log()     { printf '[deploy %s] %s\n' "$(date -u +%T)" "$*"; }
manage()  { (cd "$RELEASE" && venv/bin/python manage.py "$@"); }
lockhash() { sha256sum "$1/requirements.txt" | cut -d' ' -f1; }

switch_to() {
  ln -sfn "$1" "$APP/current.tmp"
  mv -T "$APP/current.tmp" "$APP/current"
}

healthy() {  # poll until the given release answers "ok"
  local want="ok $1" body
  for _ in $(seq 1 30); do
    body=$(curl -fsS --max-time 3 --unix-socket "$SOCK" \
      -H "Host: $HOST" -H "X-Forwarded-Proto: https" \
      http://localhost/healthz 2>/dev/null) || body=
    [[ $body == "$want" ]] && return 0
    sleep 1
  done
  return 1
}

set -a; . "$APP/shared/env"; set +a

trap 'log "build failed, removing $RELEASE"; rm -rf -- "$RELEASE"' ERR

log "unpacking into $RELEASE"
mkdir -p "$RELEASE"
tar -xzf "$ARTIFACT" -C "$RELEASE"

log "building venv"
"$PYTHON" -m venv "$RELEASE/venv"
"$RELEASE/venv/bin/pip" install --quiet -r "$RELEASE/requirements.txt"

log "preflight"
manage check --deploy
(cd "$RELEASE" && venv/bin/python -c "import myproject.wsgi")

log "collectstatic"
manage collectstatic --noinput

log "migrate"
PGOPTIONS='-c lock_timeout=5s' manage migrate --noinput

trap - ERR

PINNED=$(cat "$PINNED_FILE" 2>/dev/null || true)
MODE=restart
if systemctl is-active --quiet "$SERVICE" && [[ -d $PINNED ]] \
   && [[ $(lockhash "$PINNED") == $(lockhash "$RELEASE") ]]; then
  MODE=reload   # same dependencies as the running master: HUP is safe
fi

log "switching to $RELEASE_ID ($MODE)"
switch_to "$RELEASE"
sudo systemctl "$MODE" "$SERVICE"
[[ $MODE == restart ]] && echo "$RELEASE" > "$PINNED_FILE"

if ! healthy "$RELEASE_ID"; then
  log "health check failed"
  if [[ -n $PREVIOUS && -d $PREVIOUS ]]; then
    log "rolling back to $PREVIOUS"
    switch_to "$PREVIOUS"
    sudo systemctl restart "$SERVICE"
    echo "$PREVIOUS" > "$PINNED_FILE"
    healthy "$(basename "$PREVIOUS")" || log "ROLLBACK UNHEALTHY: intervene now"
  fi
  exit 1
fi

log "pruning, keeping $KEEP releases"
PINNED=$(cat "$PINNED_FILE")
printf '%s\n' "$APP"/releases/*/ | sed 's:/$::' | sort -r | tail -n +$((KEEP + 1)) |
while read -r dir; do
  [[ $dir == "$RELEASE" || $dir == "$PINNED" ]] && continue
  rm -rf -- "$dir"
done

log "deployed $RELEASE_ID"

Some notes on the choices. Rollback uses restart, not reload. A release that fails to import makes gunicorn's master exit with "Worker failed to boot", and there is no master left to receive a HUP. The preflight import myproject.wsgi catches most of those failures before any traffic reaches the release. The env file is loaded by both systemd and bash, so keep it to plain KEY=value lines with no quoting tricks. The flock stops two deploys, say a CI retry and a human, from both running migrations and fighting over the symlink.

A realistic deploy, step by step

Suppose you deploy the release that adds fulfilment_status, on a 4-vCPU VPS running nine sync workers. The first minute or so is unpacking, building the venv, check --deploy and the import test. All of this happens next to the live release, and users notice nothing. A pip failure at this stage removes the half-built directory and leaves production untouched. collectstatic then writes a few new hashed files into the shared root, and the old workers keep serving the files they already reference. migrate adds a column with a database default. On PostgreSQL 11 and later that is a catalogue-only change, and it finishes in milliseconds once it has its lock. For the next few seconds the old code runs against the new schema without noticing, which is the whole point of the expand step.

The lockfile hash matches the pinned release, so the script flips the symlink and sends HUP. The master forks nine new workers and sends SIGTERM to the nine old ones. The old workers finish whatever they are serving, typically within a few hundred milliseconds. For about two seconds, while the new workers import Django, incoming requests wait in the socket backlog. The first health poll may still get an answer from the old release, because it is served by a worker that has not exited yet. The second or third returns ok 20260927T101500Z and the script prunes. From the outside you see a p99 latency bump of about two seconds and no errors.

Now suppose the release had a typo in a URLconf that only breaks on import. The preflight import catches it and the deploy never switches. If the problem were something only the running service could detect, such as a missing environment variable read at request time, the health check would time out after 30 seconds. The script would then flip back, restart and verify the old release. The users caught in that window got errors. The rollback limits how many, it does not bring the number to zero.

What rollback cannot undo

Flipping the symlink restores code. It does not restore anything else:

  • Schema: the new columns stay. This is safe only because expand migrations are additive and the old code ignores them.
  • Data: rows written by the new release, for example orders with a fulfilment_status other than pending, remain. Old code must tolerate values it has never seen.
  • Cache contents: if the new release cached pickled objects whose class changed shape, old workers can fail to unpickle them. Treat cached formats like an API: add fields, do not rename them. Alternatively, bump the cache VERSION when a format changes.
  • Side effects: sent emails, charged cards and queued tasks. Tasks enqueued by the new code may be consumed by old background workers after a rollback.

For a manual rollback, for example to a release two back or when a bug surfaces an hour later, keep a separate script that skips the build entirely:

#!/usr/bin/env bash
# /srv/app/bin/rollback.sh [RELEASE_ID]  (default: the release before current)
set -euo pipefail
APP=/srv/app
CURRENT=$(readlink -f "$APP/current")

if [[ -n ${1:-} ]]; then
  TARGET=$APP/releases/$1
else
  TARGET=$(printf '%s\n' "$APP"/releases/*/ | sed 's:/$::' | sort |
           awk -v cur="$CURRENT" '$0 == cur { print prev; exit } { prev = $0 }')
fi
[[ -n $TARGET && -d $TARGET ]] || { echo "no release to roll back to" >&2; exit 1; }

ln -sfn "$TARGET" "$APP/current.tmp"
mv -T "$APP/current.tmp" "$APP/current"
sudo systemctl restart gunicorn.service
echo "$TARGET" > "$APP/shared/master-release"
echo "now serving $(basename "$TARGET")"

It restarts rather than reloads for the same reason the automatic path does. You do not know whether the target's dependencies match the running master, and with the socket held by systemd a restart costs latency, not errors.

Long requests, capacity and other processes

For a few seconds during every reload, two generations of workers exist side by side. That has consequences that are easy to miss on a small box:

  • Memory: old workers that are still draining keep their RSS while the new workers allocate theirs. With nine workers at 150 MB each, plan for about 2.7 GB at the peak, not 1.35 GB, or the OOM killer will make the choice for you. preload_app would reduce this through copy-on-write, and that is exactly the trade you give up for HUP.
  • Database connections: with CONN_MAX_AGE set, every worker holds a connection, and draining workers keep theirs until they exit. Size PostgreSQL's max_connections, or your pooler, for twice the worker count plus migrations, cron and a psql session.
  • Mixed versions: old and new code serve at the same time for up to graceful_timeout. A form rendered by the new release can be posted to an old worker. Keep request formats and session contents compatible for one release, just like the schema.
  • Long requests: a CSV export or large upload that runs longer than graceful_timeout is killed partway through. Raising the timeout just stretches the mixed-version window. Move long work to a task queue and keep web requests short.
  • Backlog limits: queued connections are bounded by the socket backlog and net.core.somaxconn (4096 by default on current kernels). nginx waits up to proxy_read_timeout (60 seconds by default) for a queued request. If a restart or worker boot ever gets close to that, you have a slow-import problem to fix, not a timeout to raise.

Other consumers of the code need the same care. Cron jobs should call /srv/app/current/venv/bin/python /srv/app/current/manage.py, so each run picks up whichever release is live when it starts. A job that is already running finishes on the old release, and that is fine. Background workers such as Celery or RQ are separate services. Restart them after the web switch, give them a drain timeout, and keep task signatures backward-compatible, because tasks enqueued by the old code will be executed by the new code.

Blue-green on one box

If you need preload_app = True, or want the new code warm and verified before it takes any traffic, run two instances from template units. gunicorn@.socket uses ListenStream=/run/gunicorn-%i.sock. gunicorn@.service uses WorkingDirectory=/srv/app/%i, ExecStart=/srv/app/%i/venv/bin/gunicorn --config /srv/app/shared/gunicorn.conf.py --chdir /srv/app/%i myproject.wsgi:application, and the same kill settings. /srv/app/blue and /srv/app/green are symlinks into releases/. nginx includes a one-line upstream file:

# /etc/nginx/myproject-upstream.conf  (included inside the upstream block)
server unix:/run/gunicorn-green.sock fail_timeout=0;

# deploy to the idle colour, verify it directly, then flip:
sudo systemctl restart gunicorn@green.service
curl -fsS --unix-socket /run/gunicorn-green.sock -H "Host: app.example.com" \
  -H "X-Forwarded-Proto: https" http://localhost/healthz
echo 'server unix:/run/gunicorn-green.sock fail_timeout=0;' \
  | sudo tee /etc/nginx/myproject-upstream.conf
sudo nginx -t && sudo systemctl reload nginx

An nginx reload is graceful: old nginx workers finish their open connections. Rolling back means writing the other socket into the file and reloading again. The cost is twice the memory and twice the database connections, and the migration rules above still apply, because both colours share one database. On a small VPS the symlink-and-HUP approach is usually the better trade.

Common mistakes

  • Leaving preload_app = True and relying on HUP. The old code keeps serving and the deploy "succeeds". The release ID in /healthz exposes this immediately.
  • Pruning the release the master was started from. After a few reload-only deploys, workers import dependencies from a venv that no longer exists.
  • Setting TimeoutStopSec below graceful_timeout, or leaving the default KillMode=control-group. Either way systemd, not gunicorn, decides when requests die.
  • Shipping a NOT NULL column without db_default, or a RemoveField in the same release that stops using the field. The old code breaks while migrate is running, and rollback breaks too.
  • Running collectstatic --clear on a shared STATIC_ROOT, which gives open tabs unstyled pages.
  • Health-checking the public URL through a CDN or cache. You end up checking a cached 200, not the new workers.

Verify it

# unit files parse cleanly
systemd-analyze verify /etc/systemd/system/gunicorn.socket /etc/systemd/system/gunicorn.service

# the socket is held by systemd, and gunicorn inherited it
systemctl status gunicorn.socket gunicorn.service
sudo ss -xlp | grep gunicorn.sock

# which release is live, and which one the master is pinned to
readlink /srv/app/current; cat /srv/app/shared/master-release

# hammer the site while deploying: expect only 200s
seq 1 5000 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
  https://app.example.com/healthz | sort | uniq -c

# a reload shows up as a signal and fresh workers booting
journalctl -u gunicorn.service -n 50 | grep -Ei 'signal|booting worker'

Then test the failure path. Ship a build whose healthz returns 500, run the script, and confirm that it exits non-zero, that current points back at the previous release, and that the load loop never showed a 502. If you only ever rehearse the happy path, the rollback will fail the first time you need it.