proxy
tls
proxy · tls · docker

Nginx Reverse Proxy
mit mkcert — HTTPS für alle lokalen Dienste

Ein einziger Nginx-Container als zentraler HTTPS-Eintrittspunkt für alle selbst gehosteten Dienste. mkcert generiert ein vertrauenswürdiges Zertifikat für alle lokalen Domains in einem Rutsch — kein Let's Encrypt, kein öffentlicher DNS, kein Zertifikats-Chaos. Einmal eingerichtet, läuft alles unter echtem HTTPS mit grünem Schloss im Browser.

Nginx Alpine mkcert Docker SAN-Zertifikat ✓ Lokales HTTPS ⚠ CA auf jedem Gerät installieren
01 Konzept 02 mkcert 03 Zertifikat 04 Nginx Setup 05 Service-Configs 06 Pi-hole DNS 07 CA verteilen 08 Fallstricke
01 — Konzept

Wie das alles zusammenspielt

Jeder selbst gehostete Dienst bekommt eine eigene .local-Domain. Pi-hole löst diese Domains auf die FortiGate WAN-IP auf. Die FortiGate leitet Port 443 per VIP an Nginx weiter. Nginx entscheidet per server_name welcher Dienst gemeint ist — und proxied den Traffic intern, auch zu Diensten auf anderen Servern.

Das Ergebnis: jeder Dienst ist unter einer lesbaren HTTPS-URL erreichbar, egal auf welchem Server er läuft. Nginx ist der einzige der ein Zertifikat braucht.

Traffic-Fluss Browser → Dienst
Browser tippt: https://paperless.local

    │  DNS-Anfrage
    ▼
Pi-hole
    paperless.local → 203.0.113.10  (FortiGate WAN-IP)

    │  HTTPS :443
    ▼
FortiGate  VIP-HTTPS → 10.0.1.71:443  (Server1)

    │  intern HTTP
    ▼
Nginx auf Server1
    server_name paperless.local
    proxy_pass  → 10.0.1.82:8000  (Server2)

    │
    ▼
Paperless-ngx antwortet  ✓
Warum nicht direkt die Server-IP im DNS?

Geräte im Mesh-Netz (192.168.68.0/22) können das FortiGate-LAN (10.0.1.0/24) nicht direkt erreichen — die Subnetze sind getrennt. Alle Zugriffe müssen über die FortiGate WAN-IP laufen. Deshalb zeigen alle lokalen DNS-Einträge auf die FortiGate WAN-IP, nicht auf die Server direkt.

02 — mkcert

Lokale CA erstellen

mkcert erstellt eine lokale Certificate Authority (CA) und generiert damit vertrauenswürdige Zertifikate für beliebige Domains — auch .local-Domains die es im öffentlichen DNS nicht gibt. Die CA wird einmalig auf Server1 erstellt und danach auf alle Geräte verteilt.

1
Abhängigkeit installieren
mkcert braucht libnss3-tools für die Firefox-Integration.
bashServer1
sudo apt install -y libnss3-tools
2
mkcert Binary installieren
Aktuelles Binary von der offiziellen Release-Seite holen.
bashServer1
wget https://dl.filippo.io/mkcert/latest?for=linux/amd64 -O mkcert
chmod +x mkcert
sudo mv mkcert /usr/local/bin/mkcert
3
Lokale CA erstellen und registrieren
Dieser Befehl erstellt die Root-CA und registriert sie im System-Zertifikatspeicher von Server1. Den CA-Pfad notieren — die Datei wird später auf alle Geräte verteilt.
bashServer1
mkcert -install

# CA-Pfad anzeigen — merken!
mkcert -CAROOT
# Ausgabe z.B.: /root/.local/share/mkcert
03 — Zertifikat

Ein SAN-Zertifikat für alle Domains

Statt für jeden Dienst ein eigenes Zertifikat zu generieren, deckt ein einziges SAN-Zertifikat (Subject Alternative Name) alle lokalen Domains ab. Das vereinfacht die Verwaltung erheblich — ein Update, eine Datei, alle Dienste.

bash Server1 — Zertifikat für alle Domains generieren
cd /opt/docker/nginx/certs

mkcert \
  navidrome.local   \
  pihole.local      \
  homepage.local    \
  portainer.local   \
  portainer2.local  \
  uptime.local      \
  mealie.local      \
  paperless.local   \
  linkwarden.local  \
  aliasvault.local  \
  joplin.local      \
  cloud.local

# mkcert benennt die Datei nach dem ersten Domain-Namen + Anzahl weiterer
# Umbenennen für saubere Referenz in allen Nginx-Configs:
mv navidrome.local+11.pem     homelab.pem
mv navidrome.local+11-key.pem homelab-key.pem
Neue Domain hinzufügen

Kommt ein neuer Dienst dazu, muss das Zertifikat neu generiert werden — mit allen bisherigen Domains plus der neuen. Danach Nginx neu laden: docker exec nginx nginx -s reload. Auf Windows zusätzlich den HSTS-Cache leeren: chrome://net-internals/#hsts → Domain eintragen → Delete. Auf Android die CA neu installieren.

04 — Nginx Setup

Docker Compose & Verzeichnisstruktur

/opt/docker/nginx/
├── docker-compose.yml
├── certs/
│   ├── homelab.pem         ← SAN-Zertifikat
│   └── homelab-key.pem     ← privater Schlüssel
└── conf.d/
    ├── 00-redirect.conf    ← HTTP → HTTPS global
    ├── navidrome.local.conf
    ├── paperless.local.conf
    ├── joplin.local.conf
    └── … eine .conf pro Domain
/opt/docker/nginx/docker-compose.yml Server1
services:
  nginx:
    image: nginx:alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./certs:/etc/nginx/certs:ro
    extra_hosts:
      - "host.docker.internal:host-gateway"

networks:
  default:
    name: proxy
    external: true
/opt/docker/nginx/conf.d/00-redirect.conf HTTP → HTTPS Redirect global
server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}
1
proxy-Netzwerk anlegen
Einmalig — wird bei docker system prune gelöscht, dann neu anlegen.
bash
docker network create proxy
2
Config-Syntax testen vor dem Start
Nginx im Einweg-Container testen — zeigt Fehler bevor der echte Container startet.
bash
docker run --rm \
  -v /opt/docker/nginx/conf.d:/etc/nginx/conf.d:ro \
  -v /opt/docker/nginx/certs:/etc/nginx/certs:ro \
  nginx:alpine nginx -t
3
Container starten
Nur wenn der Config-Test "syntax is ok" meldet.
bash
cd /opt/docker/nginx
docker compose up -d
docker compose logs -f
05 — Service-Configs

Server-Blöcke pro Dienst

Jeder Dienst bekommt eine eigene .conf-Datei in conf.d/. Das Muster ist immer gleich — nur server_name und proxy_pass ändern sich. Dienste auf anderen Servern werden einfach per IP referenziert.

Muster — dienst.local.conf Dienst auf lokalem Server
server {
    listen 443 ssl;
    server_name dienst.local;

    ssl_certificate     /etc/nginx/certs/homelab.pem;
    ssl_certificate_key /etc/nginx/certs/homelab-key.pem;

    location / {
        proxy_pass         http://10.0.1.71:PORT;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        # WebSocket (für Dienste die es brauchen)
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
    }
}
Dienstserver_nameproxy_passBesonderheit
Pi-holepihole.local10.0.1.71:8888Redirect / → /admin/
Navidromenavidrome.local10.0.1.71:4533WebSocket nötig
Portainerportainer.local10.0.1.71:9000proxy_read_timeout 86400
Paperlesspaperless.local10.0.1.82:8000Server2 — andere IP!
Joplinjoplin.local10.0.1.83:22300Server3 — nur Web-UI
AliasVaultaliasvault.local10.0.1.83:8300client_max_body_size 20M
Nextcloudcloud.local10.0.1.82:PORTclient_max_body_size 1G
Pi-hole braucht einen Root-Redirect

Die Pi-hole Web-UI liegt unter /admin/, nicht unter /. Ohne Redirect landet man auf einer leeren Seite. Lösung: in der Location-Block return 301 https://$host/admin/; für location / und dann einen separaten Block für location /admin/ mit dem proxy_pass.

06 — Pi-hole DNS

Lokale DNS-Einträge

Im Pi-hole Admin unter Local DNS → DNS Records für jede Domain einen Eintrag anlegen. Alle zeigen auf die FortiGate WAN-IP — niemals auf die Server direkt.

navidrome.local203.0.113.10
pihole.local203.0.113.10
homepage.local203.0.113.10
portainer.local203.0.113.10
paperless.local203.0.113.10
mealie.local203.0.113.10
joplin.local203.0.113.10
aliasvault.local203.0.113.10
linkwarden.local203.0.113.10
cloud.local203.0.113.10
uptime.local203.0.113.10
… alle .local203.0.113.10
07 — CA verteilen

Root-CA auf alle Geräte bringen

Die mkcert Root-CA muss auf jedem Gerät installiert werden das den lokalen Domains vertrauen soll. Ohne CA-Installation erscheint im Browser eine Zertifikatswarnung trotz gültigem Zertifikat.

bash CA-Datei exportieren — Server1
# CA-Datei in Home-Verzeichnis kopieren
cp $(mkcert -CAROOT)/rootCA.pem ~/homelab-ca.pem

# Von Windows PowerShell herunterladen:
scp user@203.0.113.10:~/homelab-ca.pem C:\Users\DeinUser\Downloads\
Windows
1. Datei in .crt umbenennen
2. Doppelklick → Zertifikat installieren
3. Lokaler Computer
4. Vertrauenswürdige Stammzertifizierungsstellen
5. Browser neu starten
Android
1. .pem-Datei aufs Gerät
2. Einstellungen → Sicherheit
3. Verschlüsselung & Anmeldedaten
4. CA-Zertifikat installieren
5. Datei auswählen
Firefox (extra)
Firefox nutzt einen eigenen Zertifikatspeicher.

Einstellungen → Datenschutz → Zertifikate anzeigen → Importieren

Auch wenn Windows die CA kennt, muss Firefox sie separat importieren.
Windows Chrome HSTS-Cache nach Zertifikat-Neugenerierung

Wird das mkcert-Zertifikat neu generiert (neue Domain hinzugefügt), speichert Chrome unter Windows den alten HSTS-Eintrag. Die Seite bleibt mit Zertifikatsfehler stecken obwohl alles korrekt ist. Fix: chrome://net-internals/#hsts öffnen, Domain eintragen, Delete klicken. Danach neu laden.

08 — Fallstricke

Was schiefgehen kann

"host not found in upstream" beim nginx -t
Passiert wenn host.docker.internal in Configs verwendet wird und der Test-Container den Hostnamen nicht auflösen kann. Fix: in allen Configs direkt die Server-IP verwenden. Schnell per sed -i 's/host.docker.internal/10.0.1.71/g' conf.d/*.conf.
proxy-Netzwerk fehlt — Nginx startet nicht
Das externe Docker-Netzwerk proxy muss vor dem Start existieren. docker compose up legt es nicht automatisch an wenn es als external: true definiert ist. Fix: docker network create proxy. Nach docker system prune erneut nötig.
Dienst auf anderem Server nicht erreichbar (502)
Der Dienst bindet auf 127.0.0.1:port statt auf die Server-IP. Nginx auf Server1 kann localhost von Server2 nicht erreichen. In der Docker Compose des Dienstes den Port auf die Server-IP binden: 10.0.1.82:8000:8000 statt 127.0.0.1:8000:8000.
Zertifikat wird nicht erkannt — grünes Schloss fehlt
Die mkcert Root-CA ist auf dem Gerät nicht installiert, oder wurde nach einer Neugenerierung des Zertifikats nicht neu installiert. CA-Datei erneut übertragen und installieren. Auf Android muss die CA nach jedem Neugenerieren neu installiert werden — die alte Version wird nicht automatisch aktualisiert.
Homepage zeigt "Host validation failed"
Homepage (das Dashboard) blockiert Anfragen von unbekannten Hostnamen. In der Docker Compose von Homepage die Umgebungsvariable setzen: HOMEPAGE_ALLOWED_HOSTS=homepage.local. Ohne diese Variable funktioniert der Nginx-Proxy nicht.
Ohne https:// landet man auf dem FortiGate-Login
VIP-HTTP (Port 80) auf der FortiGate fehlt oder die zugehörige Firewall Policy ist nicht angelegt. Nginx kann den HTTP→HTTPS-Redirect nur ausführen wenn Port 80 überhaupt an Nginx weitergeleitet wird.