Özgür Işık Damar
10 Min Lesezeit

Fünf Production-Apps auf einer Hetzner-Box für 50 Dollar im Monat

Die Vercel-Rechnung war 312 Dollar. Die Hetzner-Rechnung war 11,66 Euro. Sechs Monate später — was wirklich auf der Box läuft.

infrastrukturself-hostedhetznerkostenoptimierung

Meine letzte Vercel-Rechnung war 312 Dollar. Die davor war 287. Ich hatte drei Next.js-Sites, eine Postgres-Datenbank, die ich aus der Free-Tier herausgewachsen war, und eine Redis-Instanz, die mich pro Verbindung berechnete. Ich habe an einem Wochenende alles auf einen einzelnen Hetzner CAX21 ARM-Server migriert. Die Rechnung im Folgemonat war 11,66 Euro, dazu 4,49 Euro für eine kleinere Staging-Box. Sechs Monate später laufen fünf Dinge auf diesen zwei Boxen, und ich zahle jeden Monat 16,15 Euro. Hier ist, was tatsächlich darauf läuft.

Die Boxen

  • Box A — CAX21. 4 vCPU ARM, 8 GB RAM, 80 GB NVMe, Falkenstein. 11,66 €/Monat.
  • Box B — CAX11. 2 vCPU ARM, 4 GB RAM, 40 GB NVMe, Helsinki. 4,49 €/Monat.

Das war's. Kein Load Balancer, kein Managed Postgres, kein Redis-as-a-Service. Zwei Ampere-ARM-Boxen über ein Hetzner-Privatnetzwerk verdrahtet, beide auf schlichtem Debian 12.

ARM habe ich genommen, weil der Freund, der eine kleine Agentur betreibt, die x86-Äquivalente nutzte und für weniger mehr zahlte. Die ARM-Linie ist bei gleichem RAM etwa 40 Prozent günstiger. Nichts, was ich laufen lasse, kümmert sich um die Architektur — bis auf einen Sharp-Build, der --platform=linux/arm64 in seinem Dockerfile brauchte.

Was auf Box A läuft

In einem docker-compose, in einem Projektverzeichnis, unter einem Non-Root-User:

  1. Postgres 17. Eine Instanz, vier Datenbanken, Isolation über Rollen. stork_prod, nova_prod, portfolio, analytics. Jede Rolle besitzt ihre Datenbank und hat anderswo keine Rechte. Ich habe den Multi-Instance-Ansatz zwei Tage probiert — albern. Postgres handhabt Multi-Tenancy sauber, wenn man aufhört, dagegen zu kämpfen.
  2. Redis 7. Drei logische Datenbanken auf unterschiedlichen Indizes. App-Cache auf 0, Job-Queue auf 1, Sessions auf 2. Ein requirepass, von dem ich jetzt weiß, dass er genau 64 Zeichen hat — wegen eines Vorfalls, zu dem ich gleich komme.
  3. Caddy 2. Fünf Domains, automatisches HTTPS, ein Caddyfile. Das Caddyfile ist etwa drei zu eins kürzer als die äquivalente Nginx-Konfiguration.
  4. Stork warehouse-test. Go-API + der Build des stork-admin-panel, beide hinter Caddy auf einer Subdomain.
  5. nova-api. Zwei Go-Microservices in derselben Compose-Datei, die im internen Docker-Netzwerk miteinander reden.
  6. Prometheus + Grafana. Ja, auf derselben Box. Das Prometheus-Binary nutzt im Leerlauf 60 MB RAM, Grafana liegt bei rund 35 MB. Der ganze Observability-Stack ist 1 Prozent des verfügbaren Speichers. Ich verschiebe ihn an dem Tag, an dem ich muss.

Box A's Load Average liegt an einem normalen Nachmittag bei etwa 0,6 und springt auf 1,8, wenn nachts der Postgres-Dump läuft. CPU-Steal ist null — dedizierte ARM-Cores teilen sich nichts.

Was auf Box B läuft

  1. Postgres 17 — Test-Instanz. Sample der Prod-Daten, jeden Sonntag um 03:00 von einem Script aufgefrischt, das einen pg_dump von Box A über das Privatnetzwerk zieht und mit frischem Passwort und einer anonymisierten Users-Tabelle wiederherstellt.
  2. Membrane AI Prototyp. Ein Python-FastAPI-Service für ein Nebenprojekt. Lebt hier, weil ich keinen halbfertigen Prototyp irgendwo in der Nähe der Prod-Datenbank haben will.

Box B ist von Box A über das 10.0.0.0/16-Privatnetzwerk erreichbar und sonst nirgends. Ihre öffentliche IP existiert, aber die Firewall lehnt alles ab außer SSH von meiner Heim-IP und ICMP.

Warum kein Kubernetes

Ich habe die Operator-Zeit kalkuliert. K8s wären für mich grob 4 Stunden Wartung pro Monat — Upgrades, Cert-Rotation, die unvermeidliche kubectl describe pod-Archäologie, wenn nachts ein Pod neu startet. Fünf Apps passen in docker-compose up -d. Ich spare über Compose hinaus vielleicht 30 Minuten Deployment-Ergonomie im Monat. Der Tausch ist schlecht.

Der Kollege, der für die gleiche Art Projekt zuerst K8s probierte, hat drei Wochenenden darin verbrannt und ist zu Compose zurückgekehrt. Die Geschichte hat die Entscheidung leicht gemacht.

Warum Caddy, nicht Nginx

Zwei Gründe. Das Caddyfile liest sich, wie eine Config-Datei sich lesen sollte, und ACME ist eingebaut. Ich habe in meinem Leben nie einen certbot-Cron geschrieben und habe nicht vor, damit anzufangen.

# /etc/caddy/Caddyfile — fünf Domains, eine Datei
{
    email isikozgur35@gmail.com
}
 
ozgurdamar.dev {
    reverse_proxy portfolio:3000
    encode zstd gzip
}
 
api.stork-test.dev {
    # interner Docker-DNS — Caddy und stork-api teilen sich das Compose-Network
    reverse_proxy stork-api:8080
}
 
admin.stork-test.dev {
    reverse_proxy stork-admin:3000
}
 
nova-test.dev, *.nova-test.dev {
    reverse_proxy nova-api:8081
}
 
grafana.ozgurdamar.dev {
    # nur meine Heim-IP erreicht das Dashboard
    @home client_ip 92.45.xx.0/24
    handle @home { reverse_proxy grafana:3000 }
    handle { respond 403 }
}

Caddy erneuert die Zertifikate ohne mich. Ich habe letzte Woche bemerkt, dass es alles erneuert hatte, nur weil eine Slack-Erinnerung, die ich vor sechs Monaten gesetzt hatte, abfeuerte und ich nachsah.

Die Compose-Datei

Der ganze Stack lebt in /srv/box-a/docker-compose.yml, im Besitz des Users deploy. Die interessanten Teile:

# docker-compose.yml — gekürzt auf die tragenden Services
services:
  postgres:
    image: postgres:17-bookworm
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
      # explizite Collation — Ubuntu-Default brach nach der Migration
      LANG: C.UTF-8
      LC_COLLATE: C.UTF-8
    volumes:
      - pgdata:/var/lib/postgresql/data
    secrets: [pg_password]
 
  redis:
    image: redis:7-bookworm
    restart: unless-stopped
    # Passwort aus Datei — taucht nie in `docker inspect` auf
    command: ["redis-server", "/etc/redis/redis.conf"]
    volumes:
      - ./redis.conf:/etc/redis/redis.conf:ro
 
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    depends_on: [stork-api, stork-admin, nova-api, portfolio]

restart: unless-stopped plus die systemd-Unit unten bedeutet: Die Box bootet neu, und alles kommt zurück, ohne dass ich hinschaue.

Die systemd-Unit, die alles zusammenhält

Das ist das Eine, das ich jedem, der dieses Setup kopiert, nicht zu überspringen empfehle. Der Docker-Daemon kommt beim Boot hoch; dein Compose-Projekt nicht, außer du sagst es ihm.

# /etc/systemd/system/box-a.service
[Unit]
Description=Box A docker-compose stack
Requires=docker.service
After=docker.service network-online.target
 
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/box-a
# `up -d` ist idempotent — Retry sicher; `down` beim Stop gibt Volumes sauber frei
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
 
[Install]
WantedBy=multi-user.target

Einmal systemctl enable box-a und vergessen. Die Box ist in sechs Monaten zweimal neu gestartet — einmal für ein Kernel-Update, einmal weil ich shutdown -r statt now vertippt habe. Beide Male war alles binnen 90 Sekunden wieder da.

Backups, das eigentlich Teure

Backups gehen auf eine Hetzner Storage Box, verschlüsselt mit restic. 3,20 € im Monat für 1 TB, ich nutze ungefähr 47 GB. Das Script läuft jede Nacht um 03:30 unter cron:

#!/usr/bin/env bash
# /srv/box-a/scripts/nightly-backup.sh — pg_dump + restic, exit non-zero bei jedem Fehler
set -euo pipefail
export RESTIC_PASSWORD_FILE=/etc/restic.password
export RESTIC_REPOSITORY=sftp:u123456@u123456.your-storagebox.de:/backups
 
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DUMP_DIR="/var/backups/pg/${STAMP}"
mkdir -p "$DUMP_DIR"
 
for DB in stork_prod nova_prod portfolio analytics; do
  # Custom-Format — paralleler Restore später, komprimiert auf der Leitung
  docker exec postgres pg_dump -Fc -d "$DB" > "${DUMP_DIR}/${DB}.dump"
done
 
restic backup "$DUMP_DIR" --tag nightly --tag pg
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
 
rm -rf "$DUMP_DIR"

Die Restore-Übung, die ich vierteljährlich mache — letzten Nacht-Snapshot auf Box B ziehen, unter anderem Datenbanknamen wiederherstellen, count(*) gegen eine bekannt stabile Tabelle laufen lassen. Drei Minuten, wenn alles funktioniert. Ich habe diese Übung zweimal gemacht und einmal einen fehlenden Role-Grant erwischt.

Drei Dinge, die während der Migration kaputtgingen

Eins. Der Postgres-Collation-Unterschied. Ubuntus Default war en_US.UTF-8; das frische Debian-Image kam mit C.UTF-8 hoch. Die Stork-warehouse-Migration lief sauber, aber eine Sortierabfrage auf türkische Produktnamen lieferte die Dinge in falscher Reihenfolge. Der Bug war klein — Ş wurde nach Z statt nach S einsortiert — und das Finden hat mich drei Stunden gekostet, weil das Symptom eine UX-Beschwerde war, kein Stack-Trace. Der Fix war das explizite LC_COLLATE=C.UTF-8 in der Compose-Datei und ein einmaliges ALTER COLLATION auf den betroffenen Indizes.

Zwei. Die Memory-Decke beim Next.js-Standalone-Build. Ich habe zuerst auf der CAX11 gebaut, weil ich den Runner dort geparkt hatte. Der Build wurde zweimal OOM-killed. Die 4 GB der CAX11 reichten nicht für next build mit dem Analytics-Page-Bundle. Ich habe den Build auf Box A verschoben — 8 GB war reichlich — und synce die Standalone-Ausgabe via rsync über das Privatnetzwerk ins Deploy-Verzeichnis. Build-auf-A, Run-auf-B, Deploy-Symlink. Zehn Minuten Arbeit, Problem weg.

Drei. Das ist der Bug, der fast in Produktion gegangen wäre. Das Redis-Passwort, das ich generiert habe, war 88 Zeichen lang, base64 aus openssl rand 64. Ich habe es ohne Anführungszeichen in .env gelegt. Docker Compose hat es stillschweigend am ersten +-Zeichen abgeschnitten — denn + ist in manchen Kontexten YAML-Control, wenn es durch eine Shell läuft. Der Container ist auf 6379 hochgegangen, hat AUTH mit dem abgeschnittenen Präfix als Passwort akzeptiert — aber auch ganz ohne Passwort, wegen einer subtilen Interaktion, die ich nie ganz verstanden habe. Ich habe es erwischt, weil ich von Box B aus redis-cli -h 10.0.0.2 PING ohne Credentials ausführte und PONG zurückbekam. Box B hätte das nicht dürfen sollen. Ich habe das Passwort rotiert, diesmal ohne +-Zeichen, und generiere Redis-Passwörter jetzt mit openssl rand -hex 32 — genau 64 Hex-Zeichen, keine Shell-Spezial-Bytes.

Ich war ein docker compose up davon entfernt, ein vom Internet erreichbares Redis ohne Auth zu betreiben. Das Migrationsfenster waren die riskantesten 48 Stunden des Jahres, und das war der Bug, der mich fast etwas gekostet hätte.

Die Kostentabelle, endlich

SacheVorher (Vercel/managed)Jetzt (Hetzner)
Compute (3 Next.js-Sites)$79inklusive
Postgres (managed, 8 GB)$135inklusive
Redis (managed)$58inklusive
Bandbreite$32inklusive (20 TB/Monat)
Storage Box (Backups)3,20 €
Monatlich gesamt$31219,35 € (~$21)

Rund 14× Kostensenkung. Nicht die 18×, die ich mir im Hook gesagt habe — beim Briefumschlag-Rechnen hatte ich die Storage Box vergessen. Die Rechnung wird schlechter, wenn ich die acht Stunden des Migrations-Wochenendes einrechne, aber die Stunden haben sich nach sechs Monaten grob auf null amortisiert.

Der kontraintuitive Teil

Serverless war billig, als ich anfing. Es wurde teuer, als ich erfolgreich war. Die Kostenkurve von Managed PaaS ist nicht linear — sie ist Treppenstufe, genau in den Momenten, in denen deine App am fragilsten ist. Der Tag, an dem mein Portfolio einen unerwarteten Traffic-Peak von einem Hacker-News-Link bekam, war der Tag, an dem Vercel mir einen Rechnungs-Alert schickte. Der Tag, an dem die warehouse-test-API 50 Verbindungen zu Postgres überschritt, war der Tag, an dem die nach Verbindung berechnete Redis-Tier zum teuersten Posten meiner Rechnung wurde.

Die langweilige ARM-Box weiß nicht, dass meine App eine gute Woche hat. Sie läuft einfach.

Mach das nicht, wenn

Du keine Freude daran hast, auf OS-Ebene zu debuggen. Der Postgres-Collation-Bug hat drei Stunden gekostet. Der Redis-Passwort-Bug hätte böse enden können. Den Caddy-Renewal-Cron gibt es nicht, weil Caddy es erledigt — aber du musst Caddy vertrauen, und Vertrauen heißt, die Dokumentation einmal lesen statt überfliegen. Wenn journalctl -u box-a und docker compose logs --tail 200 nicht in deinem Muskelgedächtnis sind, sind die gesparten 290 Dollar im Monat nicht wert, was du beim ersten 2-Uhr-Page verlierst.

Ich habe Freude daran. Ich finde es erholsam. Es ist ein Hobby, das zufällig gratis ist.

Das Easter Egg

Das Teuerste auf der Rechnung ist immer noch die Storage Box für Backups. Backups fühlen sich billig an, bis du sie nicht hast. Die 3,20 € im Monat sind die Zeile, die ich nie optimieren würde.

// wenn du schon hier bist