Infrastructure

Health-Gated, Rollback-Safe Deploys for a Self-Hosted Compose Stack

TL;DR: The deploy pipeline behind my self-hosted apps on Hetzner. A single script backs up the database first, rebuilds images, runs migrations, restarts services one at a time behind health checks, and reloads the reverse proxy — with a documented, fast rollback path. Built on Docker Compose and Bash, not Kubernetes, because the right scale here is one well-run box per app.

This replaces an earlier, aspirational description of a Kubernetes + Istio blue-green pipeline that didn’t match how these apps actually ship. The real system is smaller and, for a single-operator setup, better: a Bash + Docker Compose deploy that backs up before it touches anything, sequences restarts behind health checks, and keeps rollback to a single restore-plus-reset. It runs on the same self-hosted Hetzner infrastructure described in the provisioning & deploy agent.

📋Summary

  • Problem: Deploying a multi-service stack by hand is slow and risky — easy to forget a backup, apply a migration at the wrong time, or take the whole app down at once.
  • Solution: A scripted, health-gated deploy that backs up first, migrates, restarts services sequentially behind health checks, and reloads the proxy — with rollback documented and tested.
  • Impact: Repeatable, low-anxiety deploys on a single self-hosted box; failed health checks stop the rollout, and a known-good rollback is one backup-restore + git reset away.
Deploy
1 command
Pre-deploy
auto DB backup
Restart
health-gated

📋Context

My self-hosted apps run as Docker Compose stacks (Go API, Next.js frontend, PostgreSQL, Redis, plus a reverse proxy and, on the heavier stack, Qdrant + Prometheus + Grafana) on Hetzner. Compose's default "up -d" recreates containers, so the honest goal isn't literal zero downtime — it's a deploy that's safe, sequenced, and trivially reversible, run by one operator.

Symptoms / Failure Modes

  • Hand-run deploys skip the pre-deploy backup exactly when you need it most
  • Migrations applied before the database is ready cause crash loops
  • Restarting everything at once turns a small bug into a full outage with no clean rollback

🎯Goals, Requirements, Constraints

Goals

  • One-command deploys that are safe to re-run
  • Always take a verified database backup before touching production
  • Gate each service's restart on a health check, and stop the rollout if it fails
  • Keep a fast, documented rollback to the previous known-good commit

Constraints

  • Single VM per app; one operator
  • No managed deploy service — the pipeline is Bash + Compose + the reverse proxy

Non-Goals

  • Kubernetes / service mesh / blue-green infrastructure — wrong scale for a single-box app
  • True zero-downtime container swaps (would need a second environment or rolling proxy cutover)

🏗️Approach

A single deploy script orchestrates the rollout. It validates the environment and warns on a dirty git tree, takes a database backup (abort the deploy if it fails), rebuilds images, runs migrations, then restarts services sequentially — backend first and only proceeding once its health endpoint responds, then the frontend, then a reverse-proxy reload. Flags (--skip-backup, --skip-migrations) exist for controlled cases. The companion stack uses the same staged pattern — data services and migrations before the app — to avoid first-boot crash loops.

Key Design Decisions

  1. Decision: Back up the database before every deploy, and abort if the backup fails
    Why: The cheapest insurance against a bad migration is a fresh, verified dump taken seconds earlier. If it can't be taken, the deploy shouldn't proceed.
    Alternatives: Periodic-only backups — leaves a gap exactly around the riskiest moment.
  2. Decision: Sequential, health-gated restarts
    Why: Restart the backend and wait for its /health endpoint before moving on; a failing health check stops the rollout instead of cascading. Then the frontend, then reload the proxy.
    Alternatives: Restart everything at once — faster, but a single bad image takes the whole app down with no checkpoint.
  3. Decision: Rollback via backup-restore + git reset to the previous commit
    Why: The deploy records the current commit, so reverting is straightforward — stop, restore the pre-deploy dump, git reset --hard to the prior commit, rebuild, restart. Simple and reliable.
    Alternatives: A second (blue-green) environment would enable instant cutover but doubles resources and ops for a single-box app.

⚙️Implementation

Components / Modules

  • Deploy script: Pre-flight checks, pre-deploy DB backup, image build, migrations, sequential health-gated restarts, reverse-proxy reload. Re-runnable, with --skip-backup / --skip-migrations escape hatches.
  • Backup-before-deploy: Calls the nightly backup script inline; a failed backup aborts the deploy.
  • Health gates: Backend restart waits on its /health endpoint; the frontend on its root; the rollout halts if a check fails.
  • Reverse proxy: nginx (with a certbot renewal sidecar) or Caddy (automatic TLS) fronts the stack and is reloaded after restarts.

Automation & Delivery

  • Single command runs the full sequence; CI can invoke it on merge
  • Pre-deploy database backup runs automatically (gzipped, integrity-checked, retained)
  • Health checks gate progression; failures halt the rollout

              # deploy.sh — backup first (abort on failure), then build, migrate, restart behind health checks.
if [ "$SKIP_BACKUP" != true ]; then
  bash "$BACKUP_SCRIPT" || error "Database backup failed. Aborting deployment."
fi
docker compose -f docker-compose.prod.yml build --no-cache backend frontend
[ "$SKIP_MIGRATIONS" = true ] || bash "$INIT_DB"
# restart backend, wait for health, then frontend, then reload the proxy
docker compose -f docker-compose.prod.yml up -d backend
curl -fsS http://localhost:8080/health
docker compose -f docker-compose.prod.yml up -d frontend
            

🛡️Security

Threat Model
  • Bad migration corrupts data: a fresh verified dump is taken immediately before every deploy
  • Broken image ships: sequential health gates stop the rollout before it reaches the whole stack

Controls Implemented

  • Reverse proxy enforces TLS and security headers; app services stay on the internal network
  • Secrets live in a git-ignored env file, excluded from the deploy copy

Verification

  • Pre-deploy backups verified with gzip -t before retention runs
  • Health endpoints confirm a service is actually serving before traffic continues

⚙️Operations

Observability

  • Each deploy stage is logged with clear pass/fail output
  • Heavier stack exposes Prometheus metrics + Grafana dashboards for post-deploy health

Incident Response

  • Failed deploy: stop services, restore the pre-deploy dump, git reset --hard to the previous commit, rebuild, restart
  • Health check fails mid-rollout: the script halts before the change propagates further

Cost Controls

  • Reuses the single existing VM; no second environment or managed deploy service

📊Results

Outcomes

  • Safety: Every deploy is preceded by a verified database backup, so the worst case is a quick restore to seconds earlier.
  • Reliability: Sequential health-gated restarts stop a bad build before it takes the whole app down.
  • Operability: Deploys and rollbacks are single, documented commands an operator can run with confidence.

⚖️Tradeoffs

  • Not literal zero-downtime: compose up -d recreates containers, so there's a brief restart window per service
  • Single-box simplicity over blue-green/canary — less infrastructure, but no instant cutover

🚀Next Steps

  • Add an automated rollback command (currently a documented manual sequence)
  • Optional proxy-level cutover (start new container, switch upstream, drain old) for true zero-downtime on the busier stack