← Zurück zum Journal

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.

Reverse Proxy und TLS im Homelab: Caddy verbindet interne Dienste mit einem automatisierten DNS-01-Nachweis bei IONOS.

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.

DNS-01 trennt den öffentlichen Zertifikatsnachweis vom internen Zugriffspfad über Technitium und Caddy.

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

KomponenteBeispielAufgabe
Technitium DNS192.168.10.53 und .54interne Zone und Namensauflösung
Caddy / proxy01192.168.10.60TLS-Endpunkt und Reverse Proxy
Paperless192.168.10.70:8000internes HTTP-Backend
öffentliche Domainexample.net bei IONOSBasis für öffentlich vertrauenswürdige Zertifikate
interne Zonehome.example.netNamen 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:

NameTypWert
proxy01A192.168.10.60
paperlessCNAMEproxy01.home.example.net.
grafanaCNAMEproxy01.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 NamenWildcard
kleinere Reichweite pro privatem Schlüsselein Schlüssel deckt viele Dienste ab
jeder Name kann in Certificate-Transparency-Logs sichtbar werdendort erscheint im Wesentlichen der Wildcard-Name
neue Namen führen zu neuen Bestellungenneue Dienste können vorhandenes Zertifikat nutzen
Konfiguration ist explizitein 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

SymptomWahrscheinliche UrsacheErster Check
module not registered: dns.providers.ionosCaddy ohne IONOS-Modulcaddy list-modules
Challenge endet mit 401/403Token falsch, widerrufen oder falsch zusammengesetztSecret und Format prefix.secret prüfen
TXT-Record wird nicht gefundenfalsche öffentliche Zone oder DNS-Propagationautoritative IONOS-Zone und öffentliche Abfrage prüfen
Browser erreicht falschen HostClient nutzt nicht Technitium oder hat alten Cacheaktiven Resolver und direkte dig-Abfrage prüfen
Caddy liefert 502Backend nicht erreichbar oder Port falschBackend vom Proxy-Host testen
Login oder WebSocket scheitertAnwendung kennt Proxy-/Forwarded-Header nichtanwendungsspezifische Proxy-Einstellungen prüfen
unbekannter Host liefert trotzdem 200zu großzügiger Wildcard-FallbackDefault auf abort oder 404 ändern
nach Dateiedit bleibt alte Konfiguration aktivReload sah nicht die erwartete DateiHost- 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,
  • /data und /config verschlü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.

Quellen und Vertiefung