Infrastructure Automation

A Reusable Provisioning & Deploy Agent for Self-Hosted Hetzner

TL;DR: The tooling I built to stand up and run my own infrastructure — a set of provisioning and deploy scripts that take a fresh cloud VM to a TLS-secured, backed-up production host in one command. Cloud-init bootstrap, idempotent SSH-based deploys with staged database startup and migrations, automatic TLS, and nightly backups with integrity checks and retention. The same flow runs every self-hosted project I operate on Hetzner.

Most of my projects run live on self-hosted Hetzner servers, and I set up and maintain that infrastructure myself. Rather than do it by hand each time, I built the provisioning and deploy tooling once and reuse it across every stack. It’s deliberately small — a few idempotent shell scripts that converge on Docker Compose — but it covers the full lifecycle: create the box, harden it, ship the code, run migrations, secure it with TLS, and back it up nightly. The same flow stands up Porch and my other self-hosted apps, and it’s portable across Hetzner, AWS, and GCP because the only cloud-specific part is creating the VM.

📋Summary

  • Problem: Running several self-hosted apps solo means provisioning, deploying, securing, and backing up multiple servers — by hand, that doesn't scale to one person.
  • Solution: A small, idempotent set of scripts — one bootstrap, one deploy, one backup — that work the same across Hetzner, AWS, and GCP and converge on Docker Compose.
  • Impact: New project or new server goes live in a single command; deploys are repeatable and safe; every box is firewalled, TLS-secured, and backed up nightly without manual steps.
Provisioning
1 command
Target clouds
Hetzner / AWS / GCP
Backup retention
30 days
Default cost
~€5/mo (cx22)

📋Context

I run several live products on self-hosted Hetzner servers — each a full Docker Compose stack with a database, cache, object storage, and reverse proxy. Doing the server setup, deploys, TLS, and backups by hand for each one is exactly the kind of repetitive, error-prone work that should be automated. So I built the automation once and reuse it everywhere.

🎯Goals, Requirements, Constraints

Goals

  • Take a fresh cloud VM to a running, TLS-secured production host in one command
  • Make deploys idempotent and safe to re-run, with no first-boot crash loops
  • Harden every server by default (firewall, least-exposed services, security headers)
  • Back up every box nightly with verification and retention — no manual steps
  • Avoid cloud lock-in — the same artifact deploys anywhere

Constraints

  • One operator; everything must be scriptable and idempotent
  • Cheap by default (Hetzner cx22, ~€5/mo)
  • Portable across Hetzner, AWS, and GCP with one shared bootstrap

Non-Goals

  • A general-purpose PaaS or control plane — this is purpose-built tooling for my stacks
  • Kubernetes / cluster orchestration — single-box Compose is the right scale here
  • Managed services — the whole point is owning the infrastructure

🏗️Approach

Provisioning, bootstrap, and deploy are separated into composable scripts. A per-cloud launch script creates the VM and generates secrets; a single cloud-init bootstrap installs Docker and configures the firewall; a single SSH-based deploy script ships the code, starts data services, runs migrations, then brings up the app. The cloud-specific part is only the VM creation — everything after is identical, because Docker Compose is the abstraction.

Key Design Decisions

  1. Decision: One shared bootstrap + deploy across three clouds; cloud-specific code only for VM creation
    Why: The launch scripts differ only in how they call hcloud / aws / gcloud and inject user-data. After the box exists, bootstrap and deploy are identical, so there's one code path to maintain and no lock-in.
    Alternatives: Per-cloud deploy logic, or an IaC tool like Terraform — more power, but more to operate for a handful of single-box apps.
  2. Decision: Generate secrets with openssl rand at provision time
    Why: Each environment gets unique DB/Redis/JWT/object-storage secrets via openssl rand; they're written to an env file that's git-ignored and excluded from the deploy rsync, so secrets never touch version control.
    Alternatives: A secrets manager (Vault/SSM) is heavier than needed for this footprint.
  3. Decision: Staged startup — data services and migrations before the app
    Why: Bring up Postgres + Redis, wait on pg_isready, run migrations, then start the API/worker. This eliminates the classic first-deploy crash loop where the app boots against an empty database.
    Alternatives: Start everything at once and rely on restart policies — works eventually, but noisy and slower to converge.
  4. Decision: Caddy for some stacks, nginx + certbot for others
    Why: Caddy auto-provisions TLS with zero config — ideal for simpler stacks. For stacks needing fine-grained rate limiting and tuned TLS, hardened nginx with a certbot renewal sidecar gives more control.
    Alternatives: Standardize on one — but the two stacks have genuinely different needs.

⚙️Implementation

Components / Modules

  • Per-cloud launch scripts: launch-hetzner.sh / launch-aws.sh / launch-gcp.sh — create the VM (Ubuntu 24.04), generate secrets via openssl rand, inject the bootstrap as cloud-init user-data, wait for SSH and cloud-init to finish, then invoke deploy.
  • bootstrap-server.sh (cloud-init): Idempotent first-boot setup — installs Docker CE + Compose plugin from Docker's apt repo, enables the service, and configures UFW to allow only 22/80/443 (plus 443/udp for HTTP/3).
  • deploy.sh: rsync the repo (excluding .git, node_modules, build output, and all env files), copy the prod env file, build images, start Postgres/Redis, wait for health, run migrations, then bring up the full stack. Re-runnable for updates.
  • backup scripts: Nightly gzipped Postgres dump (+ media tarball where relevant), integrity-checked, with retention — uploaded to MinIO with a 30-day lifecycle rule, or pruned on disk via find -mtime +30.

Data & State

  • Secrets: generated per-environment with openssl rand, written to a git-ignored env file, excluded from the deploy rsync
  • Persistent Docker volumes survive rebuilds, so deploys never risk the database or media

Automation & Delivery

  • One command provisions the VM and deploys the full stack
  • Cloud-init bootstrap runs unattended on first boot and is safe to re-run
  • Backup cron installed on first deploy (nightly), logging to /var/log

              # bootstrap-server.sh — idempotent first-boot: Docker + a default-deny-ish firewall.
if ! command -v docker &>/dev/null; then
  apt-get update -q && apt-get install -y -q ca-certificates curl gnupg
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
    | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
  # ... add repo ...
  apt-get install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
  systemctl enable --now docker
fi
if command -v ufw &>/dev/null; then
  ufw allow 22/tcp; ufw allow 80/tcp; ufw allow 443/tcp; ufw allow 443/udp
  ufw --force enable
fi
            

🛡️Security

Threat Model
  • Exposed services: only 22/80/443 are open; Postgres/Redis/object storage stay on the Docker internal network
  • Secret leakage: secrets are generated at provision time and never committed or rsynced
  • Unattended-host drift: provisioning is idempotent and re-runnable, so the box can be rebuilt from scratch

Controls Implemented

  • UFW firewall configured at bootstrap (deny by default; allow 22/80/443 + 443/udp)
  • Automatic TLS — Caddy (Let's Encrypt on first request) or nginx + a certbot renewal sidecar
  • Hardened reverse proxy on nginx stacks (TLS 1.2/1.3, modern ciphers, OCSP stapling, HSTS preload, rate limiting, connection limits)
  • Secrets generated per-environment and kept out of version control

Verification

  • Internal services never published to the host; reachable only via the reverse proxy
  • Backups integrity-checked with gzip -t before retention runs

⚙️Operations

Observability

  • Deploy scripts log each stage; backup runs log to /var/log with disk-space warnings
  • nginx/Caddy access logs; metrics stacks (Prometheus + Grafana) on the heavier deployments

Incident Response

  • Bad deploy: re-run deploy against the previous commit; volumes persist
  • Restore: decompress the latest verified dump and reload into Postgres
  • Lost box: re-provision from scratch with one command and restore the latest backup

Cost Controls

  • Cheap-by-default VM sizing (Hetzner cx22 ~€5/mo); no managed DB/CDN/object-storage bills
  • Self-hosted object storage for backups; on-disk retention pruning where MinIO isn't used

📊Results

Outcomes

  • Repeatability: A new project or replacement server reaches TLS-secured production in a single command, identically across three clouds.
  • Safety: Staged startup and migrations-before-app removed first-deploy crash loops; persistent volumes make redeploys non-destructive.
  • Security posture: Every box is firewalled and TLS-secured by default, with secrets generated per-environment and kept out of git.
  • Operability: Backups run nightly with integrity checks and retention; restores and full rebuilds are documented, scripted steps.

⚖️Tradeoffs

  • Bash + Compose over Terraform/Kubernetes — simpler to operate solo, but less declarative and not built for multi-node scale
  • Two reverse-proxy paths (Caddy and nginx+certbot) to fit different stacks, rather than one standard

🚀Next Steps

  • Add an automated rollback path to the deploy scripts (currently re-deploy a previous commit by hand)
  • Collapse the per-cloud launch scripts further behind a single parameterized entrypoint