Caddy als Reverse Proxy im Homelab: Automatisches HTTPS für öffentliche und interne Dienste
Sobald im Homelab mehr als zwei Dienste laufen, wird es mit IP-Adressen und Ports schnell unübersichtlich. Ein Reverse Proxy löst das: Du rufst https://cloud.example.de auf, und der Proxy leitet die Anfrage an den richtigen Container weiter. Caddy ist dafür mein Favorit, weil es HTTPS-Zertifikate komplett selbst holt und erneuert. In diesem Guide richtest du Caddy in einem LXC auf Proxmox ein, mit gültigen Zertifikaten für öffentliche Seiten und für interne Hostnamen, die nur aus deinem Heimnetz und über Tailscale erreichbar sind.
Kurz erklärt
- Caddy ist ein Webserver und Reverse Proxy. Ein Reverse Proxy nimmt Anfragen unter einem Hostnamen entgegen und reicht sie an einen Dienst im Netz weiter.
- Caddy holt Zertifikate von Let’s Encrypt automatisch. Für öffentliche Seiten per HTTP-Challenge, für interne Seiten per DNS-Challenge.
- Gedacht ist der Guide für alle mit Proxmox und eigener Domain, die ihre Dienste sauber per HTTPS erreichen wollen.
- Du brauchst einen kleinen LXC, eine Domain bei einem DNS-Anbieter mit API und für öffentliche Seiten die Ports 80 und 443 an deinem Router.
HTTP-Challenge und DNS-Challenge
Let’s Encrypt stellt ein Zertifikat nur aus, wenn du beweist, dass dir die Domain gehört. Bei der HTTP-Challenge ruft Let’s Encrypt eine Datei auf deinem Server über Port 80 ab. Das funktioniert nur, wenn der Hostname öffentlich auf deinen Anschluss zeigt und der Port offen ist. Bei der DNS-Challenge (auch DNS-01 genannt) legt Caddy kurz einen TXT-Eintrag bei deinem DNS-Anbieter an. Dafür muss der Server aus dem Internet gar nicht erreichbar sein. Genau deshalb ist die DNS-Challenge der Weg für interne Dienste wie proxmox.int.example.de, die nur im Heimnetz laufen.
Das brauchst du
- Einen Proxmox-Host. Bei mir läuft Caddy an jedem meiner zwei Standorte in einem eigenen LXC, einer davon auf einem Minisforum MS-A2.
- Eine eigene Domain, hier
example.de, deren DNS bei einem Anbieter mit API liegt, für den es ein Caddy-Modul gibt. Die Liste findest du auf GitHub untergithub.com/caddy-dns. - Einen API-Schlüssel deines DNS-Anbieters.
- Für öffentliche Seiten: eine Portfreigabe für 80 und 443 auf die IP des Caddy-LXC, zum Beispiel
192.168.1.20.
Schritt 1: Den LXC anlegen
Am einfachsten geht es mit den Proxmox VE Helper-Scripts der Community. Führe auf der Shell des Proxmox-Hosts aus:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/caddy.sh)"
Wie bei jedem fremden Script lohnt sich vorher ein Blick in den Code. Das Script fragt zuerst nach dem Betriebssystem. Wähle Debian 13, denn alle Befehle in diesem Guide setzen Debian mit systemd voraus. Die Alpine-Variante nutzt OpenRC, dort gibt es kein systemctl. Mit Debian legt das Script einen unprivilegierten LXC mit 1 Kern, 512 MB RAM und 6 GB Disk an und installiert Caddy als systemd-Service. Mehr braucht Caddy nicht. Meine beiden Proxys laufen mit 1 Kern und 512 MB bzw. 1 GB RAM. Am Ende fragt das Script, ob du xCaddy installieren willst. Das ist ein Tool, mit dem du Caddy mit Zusatzmodulen selbst baust. Für diesen Guide brauchst du es nicht, siehe Schritt 3.
Gib dem LXC eine feste IP, am besten per DHCP-Reservierung im Router. Alle Portfreigaben und DNS-Einträge zeigen später auf diese Adresse.
Schritt 2: Eine öffentliche Seite per HTTP-Challenge
Leg bei deinem DNS-Anbieter einen A-Eintrag für app.example.de auf deine öffentliche IP an und richte im Router die Portfreigabe für 80 und 443 auf den Caddy-LXC ein. Die Konfiguration steht in /etc/caddy/Caddyfile. Öffne sie mit nano /etc/caddy/Caddyfile und ersetze den Beispielinhalt (einen :80-Block mit file_server) komplett. Ganz oben kommt ein globaler Block mit deiner E-Mail-Adresse, die Let’s Encrypt für Hinweise zu deinen Zertifikaten nutzt:
{
email admin@example.de
}
(access_log) {
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 5
}
format json
}
}
app.example.de {
import access_log
reverse_proxy 192.168.1.30:8080
}
Ersetze 192.168.1.30:8080 durch IP und Port deines Dienstes. Mehr ist für eine öffentliche Seite nicht nötig. Lade die Konfiguration mit systemctl reload caddy (in Schritt 6 zeige ich dir den sicheren Ablauf mit Prüfung vorher). Sobald Caddy die Konfiguration lädt, holt es das Zertifikat per HTTP-Challenge, leitet HTTP auf HTTPS um und erneuert das Zertifikat rechtzeitig. Der Block in runden Klammern ist ein Snippet. Du definierst ihn einmal und holst ihn per import in jede Site. Snippets müssen im Caddyfile vor der ersten Site stehen, die sie nutzt. Das Snippet hier schreibt ein Access-Log im JSON-Format, das später auch CrowdSec auswerten kann.
Schritt 3: Das DNS-Modul nachrüsten
Die Standardversion von Caddy kennt keine DNS-Anbieter. Das Modul für deinen Anbieter rüstest du mit caddy add-package nach. Der Befehl lädt eine neue Caddy-Version mit allen bisherigen Modulen plus dem neuen herunter und ersetzt das Binary. Die Caddy-Doku markiert ihn als experimentell. Er holt das neue Binary von der Download-Seite auf caddyserver.com, du bist also darauf angewiesen, dass dieser Dienst gerade läuft. Bei mir hat er problemlos funktioniert. Sicher dir vorher trotzdem das aktuelle Binary mit cp /usr/bin/caddy /root/caddy.bak. Geht etwas schief, kopierst du es mit cp /root/caddy.bak /usr/bin/caddy zurück und startest Caddy neu. Ich nutze IONOS als DNS-Anbieter, deshalb hier als Beispiel:
caddy add-package github.com/caddy-dns/ionos
systemctl restart caddy
caddy list-modules | grep dns.providers
Für einen anderen Anbieter tauschst du ionos gegen den Namen aus dem caddy-dns-Repository, etwa cloudflare oder hetzner. Die letzte Zeile sollte dann zum Beispiel dns.providers.ionos ausgeben. Ein systemctl reload reicht nach dem Tausch des Binarys nicht, der Prozess muss einmal neu starten.
Alternativ baust du Caddy mit xCaddy selbst, zum Beispiel xcaddy build --with github.com/caddy-dns/ionos. Die Caddy-Doku beschreibt unter „Build from source“, wie du das eigene Binary per dpkg-divert sauber neben dem Paket installierst. Für den Einstieg ist add-package einfacher. Wenn add-package bei dir fehlschlägt, ist xCaddy der Weg, der nicht von der Download-Seite abhängt.
Schritt 4: Den API-Schlüssel sicher ablegen
Der API-Schlüssel hat im Caddyfile nichts zu suchen. Leg ihn in eine eigene Datei, die nur root lesen darf:
echo 'DNS_API_TOKEN=<dein-api-token>' > /etc/caddy/caddy.env
chmod 600 /etc/caddy/caddy.env
systemctl edit caddy
Bei IONOS besteht der Schlüssel aus einem öffentlichen Präfix und einem geheimen Teil, die du mit einem Punkt verbindest: praefix.geheimnis. Im Editor von systemctl edit trägst du ein:
[Service]
EnvironmentFile=/etc/caddy/caddy.env
Speichere und schließe den Editor. Danach systemctl restart caddy, damit die Variable geladen wird. Werte in dieser Datei schreibst du ohne Anführungszeichen. Im Caddyfile greifst du dann mit {env.DNS_API_TOKEN} darauf zu.
Schritt 5: Interne Hostnamen nur für LAN und Tailscale
Für interne Dienste nutze ich eine eigene Subdomain, hier *.int.example.de. Leg dafür bei deinem DNS-Anbieter einen Wildcard-A-Eintrag *.int auf die interne IP des Caddy-LXC an, also 192.168.1.20. Diese Adresse ist aus dem Internet nicht erreichbar, im Heimnetz und über Tailscale aber schon. Damit niemand über Umwege an diese Hostnamen kommt, sperrt ein Snippet alles außerhalb deiner Netze aus:
(int_only) {
tls {
dns ionos {env.DNS_API_TOKEN}
}
@outside not remote_ip private_ranges 100.64.0.0/10
respond @outside 403
}
proxmox.int.example.de {
import access_log
import int_only
reverse_proxy https://192.168.1.10:8006 {
transport http {
tls_insecure_skip_verify
}
}
}
ha.int.example.de {
import access_log
import int_only
reverse_proxy 192.168.1.50:8123
}
Den int_only-Block fügst du unter der Site aus Schritt 2 an. Er muss nur vor der ersten Site stehen, die ihn per import nutzt. Ersetze die IPs durch die deiner Dienste. Nutzt du einen anderen DNS-Anbieter, tauschst du dns ionos {env.DNS_API_TOKEN} gegen die Zeile aus der README des jeweiligen Moduls im caddy-dns-Repository, bei Cloudflare zum Beispiel dns cloudflare {env.DNS_API_TOKEN}.
Was hier passiert:
- Der
tls-Block sorgt dafür, dass genau diese Sites ihr Zertifikat per DNS-Challenge holen. Öffentliche Sites bleiben bei der HTTP-Challenge. Willst du alle Sites per DNS-Challenge versorgen, schreibst du stattdessenacme_dns ionos {env.DNS_API_TOKEN}in den globalen Block. remote_ipprüft die Adresse, von der die Verbindung kommt.private_rangesist eine Abkürzung für alle privaten IPv4- und IPv6-Bereiche wie192.168.0.0/16und10.0.0.0/8.100.64.0.0/10ist der Adressbereich, den Tailscale an deine Geräte vergibt.- Alle anderen bekommen ein 403 Forbidden.
Proxmox liefert auf Port 8006 nur ein selbstsigniertes Zertifikat aus. tls_insecure_skip_verify sagt Caddy, dass es das Zertifikat des Backends nicht prüfen soll. Das ist bequem, die Caddy-Doku rät aber davon ab. Sauberer ist es, das CA-Zertifikat des Backends in den LXC zu kopieren und im transport http-Block stattdessen tls_trust_pool file /etc/caddy/backend-ca.pem zu verwenden. Bei Proxmox liegt diese CA auf dem Host unter /etc/pve/pve-root-ca.pem. So mache ich es bei meinen Proxmox-Hosts. Websockets, wie sie Home Assistant oder die Proxmox-Konsole nutzen, leitet reverse_proxy ohne extra Einstellung weiter. Bei Home Assistant musst du die IP von Caddy noch als trusted_proxies eintragen, das zeige ich in Home Assistant auf Proxmox installieren.
Schritt 6: Prüfen, formatieren, neu laden
Bevor du eine geänderte Konfiguration aktivierst, sichere die alte Datei und prüfe die neue:
cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-$(date +%Y%m%d-%H%M)
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile --envfile /etc/caddy/caddy.env
systemctl reload caddy
caddy fmt rückt die Datei einheitlich ein. caddy validate lädt die Konfiguration testweise, ohne sie zu starten. Mit --envfile kennt es dabei auch deinen API-Schlüssel. systemctl reload caddy übernimmt die neue Konfiguration ohne Unterbrechung. Ist sie fehlerhaft, läuft die alte einfach weiter. Ich lege vor jeder Änderung eine Kopie mit Datum an, so komme ich mit einem cp zurück, wenn etwas schiefgeht.
Die Logs von Caddy selbst, also Zertifikate, Fehler und Reloads, landen im Journal. Die Zugriffe stehen im Access-Log:
journalctl -u caddy -f
tail -f /var/log/caddy/access.log
Caddy und CrowdSec
Sobald eine Seite öffentlich erreichbar ist, klopfen Scanner an. Das JSON-Access-Log aus Schritt 2 ist die Grundlage, um darauf zu reagieren. CrowdSec liest das Log, erkennt Scans und Bruteforce-Versuche und lässt die IPs per Firewall-Bouncer sperren. Einen eigenen Caddy-Build brauchst du dafür nicht. Wie ich das auf demselben LXC eingerichtet habe, steht in CrowdSec mit Caddy und nftables-Bouncer.
So prüfst du, ob es läuft
systemctl status caddyzeigt „active (running)“.- Im Journal siehst du für jede neue Domain, dass ein Zertifikat geholt wurde. Danach suchst du mit
journalctl -u caddy | grep -i certificate. curl -I https://app.example.deliefert eine Antwort ohne Zertifikatsfehler.- Ein interner Hostname öffnet sich im Heimnetz. Teste ihn danach einmal über das Mobilfunknetz ohne Tailscale: Dort sollte er nicht erreichbar sein.
- Ein Uptime-Check für deine wichtigsten Hostnamen meldet dir, wenn ein Zertifikat oder Backend ausfällt. Mehr dazu in Uptime Kuma und ntfy im Homelab.
Typische Probleme
Die HTTP-Challenge schlägt fehl
Prüfe, ob Port 80 wirklich beim Caddy-LXC ankommt und der A-Eintrag auf deine aktuelle öffentliche IP zeigt. Viele Anschlüsse mit DS-Lite oder CGNAT haben keine eigene öffentliche IPv4. Dann klappt die HTTP-Challenge über IPv4 nicht, und du nimmst die DNS-Challenge.
„unknown module“ oder „module not registered: dns.providers…“
Das DNS-Modul fehlt im Binary. Das passiert vor allem nach einem Update, das Caddy durch die Standardversion ersetzt hat, zum Beispiel wenn du im LXC den Update-Befehl des Helper-Scripts ausführst. Es installiert das offizielle Paket neu. Prüfe nach jedem Update mit caddy list-modules, ob das Modul noch da ist, und führe add-package gegebenenfalls erneut aus.
Interne Hostnamen lösen im Heimnetz nicht auf
Manche Router haben einen DNS-Rebind-Schutz, der öffentliche Namen mit privaten IPs blockiert. Bei einer FRITZ!Box trägst du die Domain dafür unter den Ausnahmen für den DNS-Rebind-Schutz ein. Alternativ legst du die Einträge auf einem lokalen DNS-Server an.
Interne Seite liefert 403, obwohl du zu Hause bist
Dein Gerät spricht Caddy vermutlich über eine öffentliche IPv6-Adresse an, die nicht in private_ranges liegt. Ergänze in dem Fall dein IPv6-Präfix oder setze für *.int nur einen A-Eintrag ohne AAAA. Sitzt vor Caddy noch ein weiterer Proxy, sieht remote_ip nur dessen Adresse. Dann brauchst du trusted_proxies im globalen Block und den Matcher client_ip.
Das Access-Log bleibt leer
Wenn du caddy validate als root ausführst, bevor die Log-Datei existiert, legt Caddy sie als root an. Der Dienst läuft aber als User caddy und darf dann nicht hineinschreiben. Abhilfe schafft chown caddy:caddy /var/log/caddy/access.log.
Zu viele fehlgeschlagene Versuche
Let’s Encrypt begrenzt fehlgeschlagene Validierungen. Wenn eine Challenge wiederholt scheitert, behebe erst die Ursache, bevor du weiter neu lädst.
Fazit
Caddy braucht einen winzigen LXC und ein übersichtliches Caddyfile, und um Zertifikate musst du dich danach nicht mehr kümmern. Öffentliche Seiten laufen über die HTTP-Challenge, interne Dienste mit dem DNS-Modul und dem int_only-Snippet. Bei mir hat Caddy den Nginx Proxy Manager abgelöst, weil sich alles in einer Textdatei pflegen und sichern lässt. Wie die Standorte zusammenspielen, liest du in Mein Homelab an zwei Standorten. Als Nächstes lohnt sich CrowdSec, sobald die erste Seite öffentlich ist.
Hat dir der Guide geholfen? Dann spendier mir gern einen Kaffee auf Ko-fi. Damit bleibt MappleBox werbefrei.