HTTPS für private Dienste mit Caddy: Let's Encrypt ohne öffentliche Erreichbarkeit (mit & ohne Tailscale)
Viele Selfhoster stehen irgendwann vor dem gleichen Problem:
Interne Dienste laufen stabil, aber erreichbar sind sie nur über IP-Adressen und Ports. Spätestens bei HTTPS wird es unsauber. Self-Signed Zertifikate führen zu Warnungen, und klassische Let's-Encrypt-Setups setzen voraus, dass der Dienst öffentlich erreichbar ist.
Ich wollte genau das vermeiden.
Ziel war es, interne Services ganz normal über Domains wie https://immich.example.de erreichbar zu machen – mit gültigem HTTPS-Zertifikat, aber ohne sie ins Internet zu öffnen. Der Zugriff soll ausschließlich im eigenen Netzwerk oder über Tailscale funktionieren.
In diesem Beitrag zeige ich, wie ich das mit Caddy und der DNS-01 Challenge umgesetzt habe. Das Setup funktioniert sowohl lokal im LAN als auch über Tailscale und lässt sich flexibel an beide Szenarien anpassen.
Voraussetzungen
- Eine eigene Domain (z. B. über Cloudflare, Hetzner, IONOS etc.)
- Zugriff auf die DNS-Einstellungen der Domain
(wichtig: API-Zugriff für DNS-01 Challenge erforderlich) - Ein Reverse Proxy (im Beispiel: Caddy)
- Mindestens ein interner Dienst (z. B. Immich, Nextcloud, etc.)
- Eigenes DNS im Netzwerk (z. B. AdGuard, Pi-hole)
- Optional:
- Tailscale für den Zugriff außerhalb des lokalen Netzwerks
Installation von Caddy
Bevor HTTPS funktioniert, braucht es einen Reverse Proxy, der automatisch Zertifikate über die DNS-01 Challenge beziehen kann.
Im Beispiel nutze ich Caddy, da es die Konfiguration stark vereinfacht und Let's Encrypt nativ integriert ist.
Für die DNS-01 Challenge wird zusätzlich ein DNS-Plugin benötigt. In diesem Fall Cloudflare.
Warum Cloudflare?
Cloudflare eignet sich besonders gut, da:
- eine stabile und gut dokumentierte API vorhanden ist
- DNS-Änderungen sehr schnell übernommen werden
- das Plugin direkt unterstützt wird
Andere Anbieter funktionieren ebenfalls, solange eine API verfügbar ist.
Caddy mit Cloudflare-Plugin installieren
Zuerst wird xcaddy über Go installiert. Wichtig dabei: go install legt die Binary nicht automatisch in einem systemweiten Pfad ab. Deshalb muss der Go-Binärpfad entweder direkt verwendet oder in die PATH-Variable aufgenommen werden. Anschließend wird Caddy mit dem Cloudflare-DNS-Modul neu gebaut.
apt update
apt install -y golang
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
export PATH="$PATH:$(go env GOPATH)/bin"
xcaddy version
xcaddy build --with github.com/caddy-dns/cloudflare
Nachdem Caddy mit dem Cloudflare-Modul gebaut wurde, muss die Binary noch an einen festen Ort verschoben und als systemd-Dienst eingerichtet werden.
Caddy als Dienst einrichten
Zuerst wird die Binary nach /usr/local/bin verschoben und ausführbar gemacht:
mv ./caddy /usr/local/bin/caddy
chmod +x /usr/local/bin/caddy
Danach wird die Service-Datei erstellt:
nano /etc/systemd/system/caddy.service
[Unit]
Description=Caddy
After=network.target
[Service]
User=root
Group=root
ExecStart=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Restart=unless-stopped
[Install]
WantedBy=multi-user.target
Anschließend werden die benötigten Verzeichnisse angelegt:
mkdir -p /etc/caddy
mkdir -p /var/lib/caddy
mkdir -p /var/log/caddy
Caddy konfigurieren
Jetzt folgt die eigentliche Konfiguration, bei der die Domain mit dem internen Dienst verbunden wird.
nano /etc/caddy/Caddyfile
Beispiel:
{
email EURE-EMAIL-ADRESSE
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
immich.example.de {
reverse_proxy 192.168.1.X:1234
}
immich.example.de wird dabei durch die gewünschte Domain ersetzt.
Die IP-Adresse und der Port hinter reverse_proxy müssen natürlich zum eigenen Dienst passen.
Bevor der Dienst gestartet wird, sollte die Konfiguration geprüft werden:
/usr/local/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
Cloudflare API-Token hinterlegen
Damit Caddy die DNS-01 Challenge bei Cloudflare ausführen kann, muss der API-Token als Umgebungsvariable hinterlegt werden.
mkdir -p /etc/systemd/system/caddy.service.d
nano /etc/systemd/system/caddy.service.d/env.conf
Inhalt:
[Service]
Environment="CLOUDFLARE_API_TOKEN=dein_token"
Warum das funktioniert
Let's Encrypt benötigt eine Verifizierung der Domain.
Bei der DNS-01 Challenge erfolgt diese über einen TXT-Eintrag im DNS.
Caddy erstellt diesen Eintrag automatisch bei Cloudflare, bestätigt damit die Domain und erhält anschließend ein gültiges Zertifikat.
Da die Validierung über DNS läuft, muss der Server selbst nicht öffentlich erreichbar sein.
Dienst starten
Zum Schluss wird systemd neu geladen und Caddy aktiviert:
systemctl daemon-reload
systemctl enable --now caddy
systemctl status caddy
Falls später Änderungen an der Caddyfile vorgenommen werden, reicht ein Neustart des Dienstes:
systemctl restart caddy
systemctl status caddy
Interne DNS-Auflösung (Grundlage)
Damit die Domain wie immich.example.de überhaupt funktioniert, muss sie im eigenen Netzwerk auf den Caddy-Server zeigen.
Ohne passende DNS-Auflösung funktioniert der Zugriff nicht, auch wenn das HTTPS-Zertifikat korrekt ausgestellt wurde.
Interne DNS-Auflösung mit AdGuard Home / Pi-hole
In meinem Setup läuft die DNS-Auflösung über einen eigenen DNS-Server (AdGuard Home). Statt den DNS im Router zu hinterlegen, wird dieser über Tailscale verteilt, sodass er sowohl im lokalen Netzwerk als auch von außerhalb erreichbar ist.
Alternativ kann der DNS-Server (z. B. AdGuard Home) direkt im Router (z. B. Fritzbox) als DNS-Server hinterlegt werden
Fritzbox (kurz und knapp)
- Heimnetz → Netzwerk → Netzwerkeinstellungen
- DNS-Server: IP von AdGuard Home eintragen
AdGuard / Pi-hole Eintrag
Die eigentliche Auflösung im Netzwerk erfolgt über den lokalen DNS-Server.
Damit die Domain im eigenen Netzwerk funktioniert, muss sie auf den Caddy-Server zeigen.
Das wird über den lokalen DNS-Server (z. B. AdGuard Home oder Pi-hole) gelöst.
Dort wird eine neue DNS-Umschreibung angelegt.
In anderen Tools (z. B. Pi-hole) entspricht das einem normalen DNS-Eintrag.
- Domain:
immich.example.de - Ziel (IP-Adresse):
IP des Caddy-Servers (z. B. 192.168.1.X) - Optional:
- Setzt als Domain direkt *.example.de, wenn ihr mehrere Dienste nutzten wollt
Nach dem Eintrag zeigt die Domain intern direkt auf den Reverse Proxy.
Das bedeutet konkret:
Sobald im Browser https://immich.example.de aufgerufen wird, landet die Anfrage nicht im Internet, sondern direkt bei deinem Caddy-Server im eigenen Netzwerk.
Wichtiger Unterschied zur öffentlichen DNS-Zone
Die Domain wird weiterhin bei Cloudflare verwaltet, allerdings nur für die Zertifikatsausstellung über Let's Encrypt.
Die eigentliche Auflösung im Alltag passiert über deinen lokalen DNS-Server.
Zugriff über Tailscale
Für den Zugriff außerhalb des lokalen Netzwerks kann Tailscale genutzt werden.
Damit die Domain auch dort korrekt aufgelöst wird, muss der interne DNS-Server im Tailscale Admin-Interface hinterlegt werden.
Dazu im Tailscale Admin Panel unter DNS einen neuen Nameserver hinzufügen.
-
Nameserver (Custom):
IP-Adresse des DNS-Servers (z. B. AdGuard Home oder Pi-hole) -
Split DNS aktivieren („Restrict to domain“)
Hier wird die eigene Domain eingetragen, z. B.:
example.de
Damit wird sichergestellt, dass nur Anfragen für diese Domain über den internen DNS-Server laufen.
Optional: Globaler DNS-Server in Tailscale
Zusätzlich kann unter Global nameservers derselbe DNS-Server eingetragen werden.
Wenn AdGuard Home als globaler DNS-Server genutzt wird, sollte „Override DNS servers“ aktiviert werden. Nur so ist sichergestellt, dass alle DNS-Anfragen wirklich über diesen Server laufen.
Das sorgt dafür, dass alle DNS-Anfragen über AdGuard Home oder Pi-hole laufen, auch außerhalb der eigenen Domain.
Das ist optional und hängt davon ab, ob der DNS-Server nur für interne Domains oder als zentraler DNS für alle Geräte genutzt werden soll.
Fazit
Mit diesem Setup lassen sich interne Dienste sauber über HTTPS betreiben, ohne sie öffentlich erreichbar zu machen.
Die Kombination aus Caddy, DNS-01 Challenge und lokalem DNS sorgt dafür, dass die Domain sowohl im Heimnetz als auch über Tailscale identisch funktioniert.
Fertig! 🎉