DNS-01 im Homelab: Vertrauenswürdiges HTTPS mit Caddy und IONOS
Eine detaillierte Anleitung für interne HTTPS-Dienste: Split-DNS mit Technitium, Caddy als Reverse Proxy und automatisierte Zertifikate über die IONOS DNS-API.
Im ersten Teil meines Homelab-Projekts habe ich zwei Technitium-Server aufgebaut. Interne Dienste waren danach zuverlässig über Namen erreichbar. Das löste aber noch nicht das nächste Problem: Der Browser kannte zwar paperless.home.example.net, warnte aber weiterhin vor dem Zertifikat – oder ich landete doch wieder bei einer IP-Adresse mit Portnummer.
Mein Ziel für den nächsten Schritt war deshalb klar:
https://paperless.home.example.net
ohne Zertifikatswarnung, ohne :8000 in der URL und ohne den Dienst aus dem Internet erreichbar zu machen.
Die Lösung besteht aus drei Bausteinen:
- Technitium beantwortet die internen Namen.
- Caddy nimmt die HTTPS-Verbindung entgegen und leitet sie zum richtigen Backend weiter.
- IONOS stellt per DNS-API kurzzeitig den Nachweis für die ACME-DNS-01-Challenge bereit.
Diese Anleitung beschreibt den Aufbau von Anfang bis Ende. Domain, Adressen und Dienste sind Beispiele. Sie müssen vor der Umsetzung an das eigene Netz angepasst werden.

Was DNS-01 eigentlich beweist
Für ein öffentlich vertrauenswürdiges Zertifikat muss eine Zertifizierungsstelle prüfen, ob ich die Domain kontrolliere. Bei HTTP-01 geschieht das über eine Datei, die von außen auf Port 80 erreichbar sein muss. Für rein interne Dienste ist das der falsche Weg: Ich müsste einen eingehenden Pfad öffnen, obwohl ich ihn für den eigentlichen Betrieb gar nicht brauche.
DNS-01 trennt den Nachweis vom Zugriff auf die Anwendung. Caddy legt über die IONOS-API einen temporären TXT-Record an:
_acme-challenge.home.example.net TXT "temporärer Nachweis"
Die Zertifizierungsstelle liest diesen Eintrag im öffentlichen DNS. Wenn der Wert stimmt, wird das Zertifikat ausgestellt. Anschließend entfernt Caddy den Record wieder.
Der interne Webdienst spielt bei dieser Prüfung keine Rolle. Er braucht weder eine öffentliche IP-Adresse noch eine Portweiterleitung. Auch eine wechselnde WAN-Adresse ist für diesen Nachweis unerheblich.
Die Grafik zeigt die wichtige Trennung: Oben läuft der öffentliche Kontrollpfad für die Ausstellung des Zertifikats. Unten bleibt der eigentliche Datenpfad vollständig im Heimnetz.
Zielarchitektur
| Komponente | Beispiel | Aufgabe |
|---|---|---|
| Technitium DNS | 192.168.10.53 und .54 | interne Zone und Namensauflösung |
Caddy / proxy01 | 192.168.10.60 | TLS-Endpunkt und Reverse Proxy |
| Paperless | 192.168.10.70:8000 | internes HTTP-Backend |
| öffentliche Domain | example.net bei IONOS | Basis für öffentlich vertrauenswürdige Zertifikate |
| interne Zone | home.example.net | Namen der Homelab-Dienste |
Der Name paperless.home.example.net wird intern auf 192.168.10.60 aufgelöst – also auf Caddy, nicht direkt auf Paperless. Caddy erkennt den angefragten Hostnamen und leitet die Anfrage an 192.168.10.70:8000 weiter.
Öffentlich muss für paperless.home.example.net kein A- oder AAAA-Record existieren. Nur der temporäre TXT-Record für die Challenge wird bei IONOS angelegt.
Voraussetzungen
- eine eigene öffentliche Domain, deren DNS-Zone bei IONOS verwaltet wird,
- ein interner DNS-Server wie Technitium,
- ein Linux-System, NAS oder Mini-PC für Caddy,
- Docker mit Compose oder eine native Caddy-Installation,
- eine feste interne Adresse für den Proxy,
- Zugriff der Clients auf den Proxy über TCP 443,
- Zugriff des Proxy-Hosts auf die internen Backends,
- ausgehender HTTPS- und DNS-Zugriff für Caddy.
Ich verwende Docker Compose. Der Ablauf ist bei einer nativen Installation derselbe; nur Build, Secret-Einbindung und Start unterscheiden sich.
1. Öffentliche und interne DNS-Welt trennen
Vor der Caddy-Konfiguration sollte feststehen, welche Zone welche Aufgabe hat.
Öffentlich bei IONOS
IONOS ist für example.net autoritativ. Caddy darf dort während der Challenge TXT-Records anlegen und wieder löschen. Die internen Dienste brauchen dort keine öffentlichen Adressen.
Intern in Technitium
Technitium ist im Heimnetz für home.example.net autoritativ. Dort lege ich zunächst die Proxy-Identität an:
| Name | Typ | Wert |
|---|---|---|
proxy01 | A | 192.168.10.60 |
paperless | CNAME | proxy01.home.example.net. |
grafana | CNAME | proxy01.home.example.net. |
Alternativ können alle Servicenamen als A-Records direkt auf 192.168.10.60 zeigen. CNAMEs machen die gemeinsame Abhängigkeit vom Proxy etwas sichtbarer und vereinfachen einen späteren Adresswechsel.
Wichtig ist die Richtung: Der Servicename zeigt auf Caddy. Der Caddy-Upstream zeigt dagegen auf die echte Backend-Adresse. Würde ich dort wieder paperless.home.example.net verwenden, entstünde eine Proxy-Schleife.
Die Auflösung prüfe ich direkt gegen beide internen Resolver:
dig @192.168.10.53 paperless.home.example.net A +short
dig @192.168.10.54 paperless.home.example.net A +short
Beide Antworten müssen am Ende die Proxy-Adresse 192.168.10.60 liefern.
2. IONOS-API-Schlüssel anlegen
Im IONOS Developer Portal wird zuerst der API-Zugang aktiviert und anschließend ein neuer Schlüssel erzeugt. IONOS zeigt dabei zwei Teile an:
public-prefix.secret
Genau diese durch einen Punkt verbundene Zeichenfolge benötigt das Caddy-Modul später als Token.
Der geheime Teil wird nach dem Erstellen nicht erneut angezeigt. Ich speichere ihn deshalb direkt im Password Manager und nicht in einer Notiz, einem Screenshot oder der Shell-History.
Der Schlüssel darf DNS-Einträge verändern. Ich behandle ihn daher wie ein privilegiertes Infrastruktur-Passwort:
- eigener, eindeutig benannter Schlüssel nur für Caddy,
- nicht im Caddyfile, Compose-File oder Git-Repository speichern,
- Dateirechte auf dem Proxy-Host beschränken,
- Zugriff und Rotation dokumentieren,
- nach einem vermuteten Abfluss sofort widerrufen und ersetzen.
3. Arbeitsverzeichnis und Secret vorbereiten
Auf dem Proxy-Host lege ich eine überschaubare Struktur an:
caddy/
├── .secrets/
│ └── ionos.env
├── Caddyfile
├── Dockerfile
└── docker-compose.yml
Die Secret-Datei enthält nur das Token:
IONOS_API_TOKEN=PUBLIC_PREFIX.SECRET
Danach beschränke ich die Leserechte:
chmod 700 .secrets
chmod 600 .secrets/ionos.env
Eine passende .gitignore verhindert versehentliches Einchecken:
.secrets/
*.env
4. Caddy mit IONOS-Modul bauen
Das normale Caddy-Image enthält nicht automatisch jedes DNS-Provider-Modul. Für IONOS baue ich deshalb ein eigenes Image mit xcaddy:
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/ionos
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Für den ersten Aufbau ist das bewusst einfach gehalten. Für einen reproduzierbaren Dauerbetrieb pinne ich nach dem erfolgreichen Test Caddy-Version, Modulversion beziehungsweise Commit und möglichst auch die Basisimage-Digests. Ein späteres docker compose build soll nicht unbemerkt andere Komponenten erzeugen.
Nach dem Build prüfe ich, ob das Modul wirklich enthalten ist:
docker compose build
docker compose run --rm caddy caddy list-modules | grep dns.providers.ionos
Die Ausgabe muss dns.providers.ionos enthalten. Ein Standard-Caddy ohne dieses Modul kann die IONOS-Challenge nicht ausführen.
5. Docker Compose konfigurieren
Eine minimale Compose-Datei sieht so aus:
services:
caddy:
build:
context: .
dockerfile: Dockerfile
container_name: proxy01
restart: unless-stopped
env_file:
- ./.secrets/ionos.env
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Die veröffentlichten Container-Ports machen Caddy im LAN erreichbar. Sie sind keine Aufforderung, am Internet-Router Port 80 oder 443 weiterzuleiten. Meine Firewall erlaubt den Zugriff nur aus den internen Netzen, die diese Dienste verwenden dürfen.
Die Volumes sind nicht optionaler Cache. In /data liegen unter anderem Zertifikate, private Schlüssel und der ACME-Zustand. Beide Volumes gehören deshalb in das Backup-Konzept.
6. Den ersten Dienst im Caddyfile eintragen
Für den ersten Test beginne ich mit genau einem Namen:
{
email acme@example.net
}
paperless.home.example.net {
tls {
dns ionos {env.IONOS_API_TOKEN}
}
reverse_proxy 192.168.10.70:8000
}
Die E-Mail-Adresse gehört zum ACME-Konto. dns ionos aktiviert die DNS-Challenge und liest das Token aus der Umgebungsvariable. reverse_proxy zeigt auf die echte Backend-Adresse.
Vor dem Start validiere ich die Konfiguration:
docker compose config --quiet
docker compose run --rm caddy \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
Dabei darf weder die aufgelöste Compose-Konfiguration noch ein vollständiger Container-Inspect in öffentliche Tickets oder Screenshots kopiert werden: Je nach Werkzeug können darin Environment-Werte auftauchen.
7. Die Challenge zuerst gegen die Staging-CA testen
Fehlerhafte Wiederholungen können ACME-Rate-Limits auslösen. Für den ersten Durchlauf verwende ich deshalb die Staging-Umgebung von Let’s Encrypt – aber mit einem eigenen temporären Testnamen. So bleibt das nicht vertrauenswürdige Staging-Zertifikat sauber vom späteren Produktivnamen getrennt:
acme-test.home.example.net {
tls {
ca https://acme-staging-v02.api.letsencrypt.org/directory
dns ionos {env.IONOS_API_TOKEN}
}
respond "DNS-01-Staging-Test erfolgreich" 200
}
Für die Ausstellung per DNS-01 benötigt dieser Testname keinen öffentlichen A- oder AAAA-Record. Dann starte ich Caddy und beobachte nur die relevanten Logs:
docker compose up -d --build
docker compose logs -f caddy
Gesucht sind Meldungen, die eine erfolgreiche Challenge und Zertifikatsausstellung bestätigen. Ein Staging-Zertifikat ist absichtlich nicht öffentlich vertrauenswürdig; eine Browserwarnung ist in diesem Schritt also erwartbar.
Während der Challenge kann der temporäre Record mit einem öffentlichen Resolver beobachtet werden:
dig TXT _acme-challenge.acme-test.home.example.net @1.1.1.1
Der Record existiert nur kurz. Wenn die Abfrage ihn verpasst, sind erfolgreiche Caddy-Logs der bessere Nachweis.
Erst wenn dieser Test stabil funktioniert, entferne ich den gesamten acme-test-Block, setze wieder den vorbereiteten paperless-Block aus Schritt 6 ein und lade die Konfiguration neu. Da der Produktivname nie über die Staging-CA bestellt wurde, fordert Caddy für ihn nun ein reguläres Zertifikat an.
8. Produktives Zertifikat und Datenpfad prüfen
Nach dem Wechsel von Staging auf Produktion teste ich die Kette in einzelnen Schichten.
Interne Namensauflösung
dig paperless.home.example.net A +short
Erwartet wird die interne Adresse von Caddy.
HTTPS und Zertifikatsname
curl -I https://paperless.home.example.net
Für mehr Zertifikatsdetails:
openssl s_client \
-connect paperless.home.example.net:443 \
-servername paperless.home.example.net </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
Backend aus Sicht des Proxy-Hosts
curl -I http://192.168.10.70:8000
Ein Fehler hier ist kein TLS-Problem. Dann erreicht Caddy das Backend nicht, der Port stimmt nicht oder die Anwendung hört nur auf einer anderen Schnittstelle.
Keine öffentliche Anwendung
dig paperless.home.example.net A @1.1.1.1
dig paperless.home.example.net AAAA @1.1.1.1
Für mein Design sollen diese öffentlichen Abfragen keine erreichbare Adresse des Homelabs liefern. Zusätzlich prüfe ich am Router, dass keine WAN-Portweiterleitung auf Caddy oder das Backend existiert.
9. Weitere Dienste ergänzen
Wenn der erste Pfad funktioniert, ergänze ich die Dienste einzeln:
grafana.home.example.net {
tls {
dns ionos {env.IONOS_API_TOKEN}
}
reverse_proxy 192.168.10.71:3000
}
homeassistant.home.example.net {
tls {
dns ionos {env.IONOS_API_TOKEN}
}
reverse_proxy 192.168.10.72:8123
}
Nach jeder Änderung folgt derselbe kleine Ablauf:
docker compose run --rm caddy \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose exec caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Danach teste ich nicht nur HTTP 200, sondern die eigentliche Anwendung: Anmeldung, Redirects, WebSockets und mindestens eine typische Leseaktion. Home Assistant benötigt beispielsweise eine passende trusted_proxies-Konfiguration für die tatsächliche Caddy-Adresse.
10. Einzelzertifikate oder Wildcard?
DNS-01 kann auch ein Zertifikat für *.home.example.net ausstellen. Das ist bequem, weil neue Subdomains ohne neue Zertifikatsbestellung unter dasselbe Zertifikat fallen können.
Ich entscheide das bewusst und nicht nur aus Bequemlichkeit:
| Einzelne Namen | Wildcard |
|---|---|
| kleinere Reichweite pro privatem Schlüssel | ein Schlüssel deckt viele Dienste ab |
| jeder Name kann in Certificate-Transparency-Logs sichtbar werden | dort erscheint im Wesentlichen der Wildcard-Name |
| neue Namen führen zu neuen Bestellungen | neue Dienste können vorhandenes Zertifikat nutzen |
| Konfiguration ist explizit | ein Catch-all braucht einen sicheren Fallback |
Ein Wildcard-Zertifikat erzeugt weder DNS-Records noch Proxy-Regeln. Es entscheidet nur, für welche Namen das Zertifikat gültig ist.
Wenn ich einen Wildcard-VHost verwende, bekommt ein unbekannter Host keinen freundlichen Testtext und schon gar nicht versehentlich einen Backend-Zugriff. Der Fallback wird abgebrochen:
*.home.example.net {
tls {
dns ionos {env.IONOS_API_TOKEN}
}
@paperless host paperless.home.example.net
handle @paperless {
reverse_proxy 192.168.10.70:8000
}
@grafana host grafana.home.example.net
handle @grafana {
reverse_proxy 192.168.10.71:3000
}
handle {
abort
}
}
Was in diesem Aufbau tatsächlich verschlüsselt ist
Das öffentlich vertrauenswürdige Zertifikat schützt zunächst den Weg vom Client bis Caddy:
Client ═════ HTTPS ═════> Caddy ─── HTTP ───> Backend
Die Verbindung vom Proxy zum Backend läuft in den Beispielen über HTTP. Das ist kein Ende-zu-Ende-TLS. In einem klar abgegrenzten Servernetz kann das eine bewusste Entscheidung sein; über fremde oder weniger vertrauenswürdige Segmente wäre es mir nicht genug.
Backend-TLS ist ein eigenes Design: Caddy muss dann der internen Zertifizierungsstelle vertrauen, der Name im Zertifikat muss zum Upstream passen und die Anwendung muss HTTPS sauber unterstützen. Ich würde das nicht mit tls_insecure_skip_verify „lösen“, nur damit die Fehlermeldung verschwindet.
Typische Fehlerbilder
| Symptom | Wahrscheinliche Ursache | Erster Check |
|---|---|---|
module not registered: dns.providers.ionos | Caddy ohne IONOS-Modul | caddy list-modules |
| Challenge endet mit 401/403 | Token falsch, widerrufen oder falsch zusammengesetzt | Secret und Format prefix.secret prüfen |
| TXT-Record wird nicht gefunden | falsche öffentliche Zone oder DNS-Propagation | autoritative IONOS-Zone und öffentliche Abfrage prüfen |
| Browser erreicht falschen Host | Client nutzt nicht Technitium oder hat alten Cache | aktiven Resolver und direkte dig-Abfrage prüfen |
| Caddy liefert 502 | Backend nicht erreichbar oder Port falsch | Backend vom Proxy-Host testen |
| Login oder WebSocket scheitert | Anwendung kennt Proxy-/Forwarded-Header nicht | anwendungsspezifische Proxy-Einstellungen prüfen |
| unbekannter Host liefert trotzdem 200 | zu großzügiger Wildcard-Fallback | Default auf abort oder 404 ändern |
| nach Dateiedit bleibt alte Konfiguration aktiv | Reload sah nicht die erwartete Datei | Host- und Containersicht der Caddyfile vergleichen |
Der letzte Punkt ist mir tatsächlich begegnet: Manche Editoren ersetzen eine Datei durch einen neuen Inode. Ein einzelner Docker-Bind-Mount kann dann noch die alte Datei sehen. Vor einem hektischen Fehlersuchen vergleiche ich deshalb die Caddyfile auf dem Host mit /etc/caddy/Caddyfile im Container.
Betrieb, Backup und Härtung
Mit dem ersten grünen Schloss ist die Arbeit nicht beendet. Für den Dauerbetrieb gehören für mich mindestens diese Punkte dazu:
- Ablaufdaten und fehlgeschlagene Erneuerungen überwachen,
- Caddy-Logs auf ACME- und Upstream-Fehler prüfen,
- API-Token getrennt sichern und regelmäßig rotieren,
- Caddyfile, Compose-Datei und reproduzierbaren Build versionieren,
/dataund/configverschlüsselt sichern,- Wiederherstellung des Proxy einschließlich Zertifikatszustand testen,
- Zugriff auf Port 443 auf die benötigten internen Netze begrenzen,
- die Backend-Ziele dokumentieren und separat erreichbar halten,
- nach jedem Change einen echten Anwendungstest durchführen.
Der Reverse Proxy ist außerdem eine neue Failure Domain. Wenn proxy01 ausfällt, funktioniert DNS weiter, aber die Anwendungen hinter den HTTPS-Namen sind nicht erreichbar. Das ist kein Argument gegen den Proxy. Es ist eine Abhängigkeit, die sichtbar gemacht, überwacht und wiederherstellbar sein muss.
Mein Ergebnis
Der entscheidende Schritt war nicht Caddy allein und auch nicht das Zertifikat allein. Erst die saubere Trennung der Rollen macht den Aufbau verständlich:
- Technitium entscheidet intern, wohin ein Dienstname zeigt.
- Caddy beendet TLS und verteilt die Anfragen auf die Backends.
- IONOS beantwortet nur den öffentlichen Eigentumsnachweis.
- Die Zertifizierungsstelle sieht den TXT-Record, aber keinen internen Dienst.
Damit werden aus internen IP-Adressen und Portnummern stabile URLs mit vertrauenswürdiger Verschlüsselung – ohne die Anwendungen ins Internet zu stellen.