Einige Links auf dieser Seite sind Affiliate-Links: bei einer Anmeldung darüber erhält diese Seite eine Provision, für Sie ohne Mehrkosten. Preise und Empfehlungen bleiben davon unberührt.
Diese Seite läuft auf einem kleinen VPS. Auf derselben Maschine: ein statischer Blog, eine selbst gehostete Analytics-Installation, zwei interne APIs und eine passwortgeschützte Anwendung. Ein einziger Caddy-Container terminiert TLS für alle, erneuert Zertifikate ungefragt und hat seit der Einrichtung exakt null manuelle Eingriffe gebraucht.
Dieser Beitrag ist genau dieses Setup, inklusive Konfigurationsdateien, plus die zwei Fehler, die wir dabei gemacht haben. Wenn Sie zuerst die Hardwareseite brauchen: Die Optionen haben wir im VPS-Vergleich gegenübergestellt - alles Folgende läuft bequem auf der günstigsten Klasse von dort.
Warum Caddy statt nginx
nginx ist hervorragend, und wir betreiben es in diesem Stack weiterhin als Auslieferer statischer Dateien. Aber am Rand, wo die Anfragen ankommen, gewinnt Caddy bei einer Sache, und die zählt in dieser Größenordnung: TLS ist kein Projekt mehr. Kein certbot, kein Erneuerungs-Cron, keine --dry-run-Generalprobe um zwei Uhr nachts. Sie schreiben einen Hostnamen hin, Caddy holt das Zertifikat bei der ersten Anfrage und erneuert es im Hintergrund.
Der Kompromiss ist real: weniger Stellschrauben, ein kleineres Ökosystem fertiger Konfigurationen und eine eigene Konfigurationssprache statt der nginx-Syntax, die Sie schon können. Für einen Ein-Maschinen-Betrieb geht dieser Tausch klar zu Ihren Gunsten aus.
Die Compose-Datei
Fünf Dienste, ein Netz, keine Orchestrierung:
services:
blog:
image: nginx:alpine
container_name: blog
restart: unless-stopped
volumes:
- ./site:/usr/share/nginx/html:ro
analytics-db:
image: postgres:17-alpine
container_name: analytics-db
restart: unless-stopped
env_file: [.env.analytics]
volumes:
- analytics_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U analytics"]
interval: 10s
timeout: 5s
retries: 10
analytics:
image: ghcr.io/umami-software/umami:postgresql-latest
container_name: analytics
restart: unless-stopped
env_file: [.env.analytics]
depends_on:
analytics-db:
condition: service_healthy
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./caddy:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
volumes:
analytics_db:
caddy_data:
caddy_config:
Vier Details darin sind tragend.
Nur Caddy veröffentlicht Ports. Sonst bindet sich nichts an den Host. Blog und Analytics sind im Compose-Netz über den Containernamen erreichbar und sonst nirgends. Das ist der größte Sicherheitsgewinn dieses Aufbaus: Sie können keine Datenbank versehentlich ins Internet stellen, wenn sie keinen veröffentlichten Port hat.
caddy_data ist ein benanntes Volume, kein Bind-Mount. Darin liegen die Zertifikate und der ACME-Account-Key. Verlieren Sie es, wird beim nächsten Start alles neu ausgestellt - überlebbar, aber beim wiederholten Debuggen laufen Sie in Rate Limits.
Die Datenbank hat einen Healthcheck, und die Anwendung wartet darauf. Ohne condition: service_healthy startet der App-Container zuerst, bekommt keine Verbindung und startet in einer Schleife neu, bis Postgres oben ist. Es konvergiert so oder so, aber die Logs sind ein Durcheinander, und ein erstes Deployment sieht kaputt aus, obwohl es das nicht ist.
restart: unless-stopped, nicht always. Der Unterschied zeigt sich, wenn Sie einen Container absichtlich zum Debuggen stoppen und danach den Host neu starten: always holt ihn gegen Ihren Willen zurück.
Das Caddyfile
Ein Block pro Hostname. Das ist die echte Struktur, unsere Namen durch Platzhalter ersetzt:
blog.example.com {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer-when-downgrade
-Server
}
# Assets mit Hash im Dateinamen dürfen hart gecacht werden, HTML nicht
@immutable path /assets/* /favicon*.png /apple-touch-icon.png /og-default.png
header @immutable Cache-Control "public, max-age=31536000, immutable"
header /favicon.ico Cache-Control "public, max-age=604800"
reverse_proxy blog:80
}
stats.example.com {
encode zstd gzip
header -Server
reverse_proxy analytics:3000
}
app.example.com {
basic_auth {
admin $2a$14$...bcrypt-Hash hierher...
}
encode zstd gzip
reverse_proxy app:3000
}
Das ist die vollständige TLS-Konfiguration: es gibt keine. Jeder Hostname bekommt bei der ersten Anfrage ein Zertifikat, erneuert automatisch, mit HTTP-zu-HTTPS-Weiterleitung gratis dazu.
Drei Dinge lohnen sich zum Übernehmen.
-Server entfernt den Server-Header. Kleinigkeit, aber kostenlos. Caddy stellt sich sonst vor.
Die Cache-Trennung ist wichtiger, als sie aussieht. Asset-Dateinamen mit Hash dürfen ein Jahr gecacht werden, weil eine Änderung am Inhalt einen neuen Dateinamen erzeugt. HTML darf das nicht, sonst lesen Besucher nach der Veröffentlichung weiter die alte Seite. Das zu vertauschen ist der klassische Fehler „ich habe deployt, es hat sich nichts geändert" - und für Sie unsichtbar, weil Ihr eigener Browser beim Neuladen revalidiert.
basic_auth erwartet einen bcrypt-Hash, kein Passwort. Erzeugen Sie ihn mit docker exec caddy caddy hash-password. Klartext gehört nie in die Datei.
Weiterleitungen gehören in die Konfiguration
Unsere Affiliate-Links zeigen nie direkt auf einen Partner. Jeder läuft über eine Weiterleitung in der Edge-Konfiguration:
redir /go/vendor-a* https://www.vendor-a.example/ 302
redir /go/vendor-b* https://www.vendor-b.example/ 302
redir /go/* /affiliate-disclosure/ 302
Zwei Gründe, und an den zweiten denkt niemand, bis er zuschlägt:
- Partner-URLs ändern sich. Tracking-Parameter werden neu ausgegeben, Programme ziehen auf andere Domains um. So ändern Sie eine Zeile und laden Caddy neu; ohne das schreiben Sie jeden Artikel um, der je dorthin verlinkt hat.
- Der Catch-all schützt Sie. Dass
/go/*auf die Offenlegungsseite fällt, heißt: ein Tippfehler oder ein eingestelltes Programm ergibt eine echte Seite statt eines 404 mitten im monetarisierten Artikel.
Lassen Sie das abschließende * in jedem Muster stehen, damit Query-Strings den Sprung überleben, und bleiben Sie bei 302 statt 301: eine permanente Weiterleitung auf eine Partner-URL, die Sie irgendwann ändern, hängt in Browsern fest, die Ihnen nicht gehören.
Zwei Fehler, die wir wirklich gemacht haben
Ein Bind-Mount auf eine einzelne Datei hat eine tote Inode festgenagelt. Das Caddyfile war als ./Caddyfile:/etc/caddy/Caddyfile eingebunden. Änderungen auf dem Host kamen im Container nicht mehr an: Docker mountet eine Datei über ihre Inode, und die meisten Editoren schreiben eine neue Datei und benennen sie über die alte - der Container behält die gelöschte Originaldatei. Die Seite lieferte tagelang eine alte Konfiguration aus, ohne eine Zeile im Log. Lösung: das Verzeichnis mounten, nicht die Datei, so wie in der Compose-Datei oben.
Ein unbeschränkter Deploy-Key, weiterhin in Benutzung. Der Deploy-Key des Blogs ist auf ein einziges rsync-Ziel festgenagelt, ein älterer CI-Key für eine andere Anwendung auf derselben Maschine dagegen wurde ganz ohne Einschränkungen angelegt: Wer ihn hat, bekommt eine interaktive Shell statt Schreibrecht auf ein Verzeichnis. Er funktioniert seit Monaten einwandfrei, genau deshalb hat niemand hingesehen. Prüfen Sie jede Zeile in authorized_keys, nicht nur die zuletzt eingerichtete - der nächste Beitrag handelt davon, wie man das richtig macht.
Betrieb
Die gesamte Wartungsoberfläche sind drei Befehle:
docker compose up -d # Änderungen anwenden
docker exec caddy caddy reload --config /etc/caddy/Caddyfile # nur Konfiguration, ohne Ausfall
docker compose logs -f caddy # den ACME-Tanz bei einem neuen Hostnamen beobachten
Eine neue Site kommt als Dienst plus Hostname-Block dazu, dann reload. Die erste Anfrage an den neuen Namen dauert ein, zwei Sekunden, solange das Zertifikat ausgestellt wird; danach ist sie von den anderen nicht zu unterscheiden.
Ein Hinweis zu Zertifikaten: Liegt Ihr DNS hinter einem Proxy, der selbst TLS terminiert, kann die von Caddy bevorzugte ACME-Challenge auf eine Weise scheitern, die wie ein Caddy-Bug aussieht. Zeigen Sie den Eintrag während der Einrichtung direkt auf die Maschine und setzen Sie den Proxy danach wieder davor, wenn Sie ihn wollen.
Wie es weitergeht
Der Gegenpart: auf diese Maschine aus GitHub Actions deployen, ohne der CI eine Shell zu geben - das eingeschränkte rsync-Setup, das wir vom ersten Tag an hätten nehmen sollen.