HTTPS für private Dienste mit Caddy: Let's Encrypt ohne öffentliche Erreichbarkeit (mit & ohne Tailscale)

Interne Dienste mit HTTPS betreiben, ohne sie öffentlich erreichbar zu machen. Mit Caddy, Let's Encrypt (DNS-01) und optional Tailscale sauber umgesetzt.

HTTPS für private Dienste mit Caddy: Let's Encrypt ohne öffentliche Erreichbarkeit (mit & ohne Tailscale)
Photo by User_Pascal / Unsplash

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 öffentliche DNS-Zone (Cloudflare) wird für die Zertifikatsausstellung genutzt.
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.

💡
Der große Vorteil dabei: Es wird keine separate Konfiguration für externe Zugriffe benötigt. Die Domain funktioniert überall identisch, solange eine Verbindung ins Netzwerk (direkt oder über Tailscale) besteht.

Optional: Globaler DNS-Server in Tailscale

Zusätzlich kann unter Global nameservers derselbe DNS-Server eingetragen werden.

💡
Hinweis:
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! 🎉