\section{HTTPS mit nginx-proxy und Let's Encrypt} \label{sec:step05} In diesem Schritt richten wir \textbf{HTTPS} für unsere Domain ein. Dafür nutzen wir zwei Docker-Container: \texttt{nginx-proxy} als Reverse Proxy und \texttt{acme-companion} für automatische SSL-Zertifikate von Let's Encrypt. Nach diesem Schritt ist unsere App unter \texttt{https://robre.de} erreichbar und erfüllt alle Voraussetzungen für die PWA-Installation. \subsection{Warum brauchen wir HTTPS?} HTTP (Hypertext Transfer Protocol) sendet alle Daten im \textbf{Klartext}. Ein Angreifer im gleichen Netzwerk kann mitlesen, manipulieren oder die Verbindung kapern. HTTPS (HTTP Secure) verschlüsselt die gesamte Kommunikation zwischen Browser und Server mittels TLS (Transport Layer Security). \textbf{Für Progressive Web Apps ist HTTPS zwingend vorgeschrieben:} \begin{itemize} \item Der \textbf{Service Worker} – das Herzstück einer PWA – funktioniert nur mit HTTPS \item Browser verweigern die PWA-Installation bei unverschlüsselten Verbindungen \item Moderne Browser-APIs (Geolocation, Kamera, Push-Benachrichtigungen) erfordern HTTPS \end{itemize} \subsection{Wie funktioniert das SSL-Zertifikat von Let's Encrypt?} \textbf{Let's Encrypt} ist eine kostenlose Zertifizierungsstelle (Certificate Authority). Der Ablauf der Zertifikatserstellung läuft automatisch nach dem \textbf{ACME-Protokoll} (Automatic Certificate Management Environment): \begin{enumerate} \item Der \texttt{acme-companion} fordert ein Zertifikat für \texttt{robre.de} an \item Let's Encrypt stellt eine \textbf{Challenge} – eine Aufgabe, die beweist, dass du die Domain kontrollierst \item Der \texttt{acme-companion} legt eine temporäre Datei auf deinem Webserver ab \item Let's Encrypt prüft, ob diese Datei unter \texttt{http://robre.de/.well-known/acme-challenge/...} erreichbar ist \item Wenn ja: Zertifikat wird ausgestellt und automatisch in den nginx eingebunden \item Das Zertifikat ist 90 Tage gültig und wird automatisch erneuert \end{enumerate} \textbf{Wichtige Voraussetzung:} Die Domain muss weltweit über DNS erreichbar sein (\textbf{DNS-Propagation}). Ohne funktionierende DNS-Auflösung schlägt die Challenge fehl – Let's Encrypt kann die Domain nicht finden und verweigert die Zertifikatsausstellung. \subsection{DNS-Propagation: Wie lange dauert es?} Wenn du eine neue Domain registrierst oder DNS-Einträge änderst, dauert es eine Weile, bis alle DNS-Server weltweit die neuen Informationen kennen. Diesen Prozess nennt man \textbf{DNS-Propagation}. \begin{itemize} \item \textbf{Normale Dauer:} 15 Minuten bis 2 Stunden \item \textbf{Maximale Dauer:} Bis zu 48 Stunden (in seltenen Fällen) \item \textbf{Beeinflussender Faktor:} Der \textbf{TTL-Wert} (Time To Live) der DNS-Einträge. Unser Wert von 86400 Sekunden (24 Stunden) erlaubt anderen DNS-Servern, die Information bis zu 24 Stunden zwischenzuspeichern. \end{itemize} \textbf{So prüfst du den Status der Propagation:} \begin{lstlisting}[language=Bash, caption={DNS-Auflösung lokal prüfen}] # Über deinen Standard-DNS-Resolver nslookup robre.de # Direkt bei Contabos Nameserver (ohne Cache) nslookup robre.de ns1.contabo.net # Über Googles öffentlichen DNS (8.8.8.8) nslookup robre.de 8.8.8.8 \end{lstlisting} Erscheint als Antwort \texttt{185.209.229.167}, ist die Propagation für diesen Server abgeschlossen. Erscheint \texttt{NXDOMAIN} (Non-Existent Domain), kennt der DNS-Server die Domain noch nicht. \textbf{Online-Tool zur weltweiten Prüfung:} \texttt{https://dnschecker.org} zeigt auf einer Weltkarte, an welchen Standorten die Domain bereits aufgelöst wird. \subsection{Architektur: Wie hängen die Container zusammen?} Nach diesem Schritt sieht unsere Docker-Infrastruktur so aus: \begin{verbatim} Internet | | HTTPS (Port 443) v nginx-proxy (Reverse Proxy) | +-- robre.de --> fitness-web (Port 80) | | | | /api/* --> fitness-api (Port 5000) | +-- SSL-Zertifikate von acme-companion verwaltet \end{verbatim} \textbf{Erklärung der Komponenten:} \begin{itemize} \item \texttt{nginx-proxy}: Empfängt alle HTTP/HTTPS-Anfragen und leitet sie an den richtigen Container weiter. Entscheidet anhand des \texttt{Host}-Headers (enthält die Domain), welcher Container die Anfrage bekommt. \item \texttt{acme-companion}: Überwacht laufende Container auf die Umgebungsvariable \texttt{LETSENCRYPT\_HOST}. Wenn eine neue Domain auftaucht, fordert er automatisch ein SSL-Zertifikat an und konfiguriert nginx. \item \texttt{fitness-web}: Unser React-Frontend mit Nginx. Braucht selbst kein SSL – der Proxy übernimmt die Verschlüsselung. \item \texttt{fitness-api}: Unser .NET-Backend. Nur über das interne Docker-Netzwerk erreichbar, nicht direkt von außen. \end{itemize} \subsection{Schritt-für-Schritt: HTTPS einrichten} \subsubsection{Schritt 1: Bestehende Container stoppen und löschen} Da wir von manuellen \texttt{docker run}-Befehlen auf \texttt{docker compose} umsteigen, müssen die alten Container entfernt werden. \begin{lstlisting}[language=Bash, caption={Alte Container stoppen und löschen}] ssh testserver docker stop fitness-web fitness-api docker rm fitness-web fitness-api \end{lstlisting} \subsubsection{Schritt 2: Verzeichnis für docker-compose anlegen} \begin{lstlisting}[language=Bash, caption={Projektverzeichnis auf dem Server}] mkdir -p /opt/fitness cd /opt/fitness \end{lstlisting} \subsubsection{Schritt 3: docker-compose.yml erstellen} \texttt{docker compose} ist ein Tool, mit dem wir mehrere Container als \textbf{eine Einheit} definieren und verwalten können. Statt vier einzelner \texttt{docker run}-Befehle definieren wir alle Container in einer YAML-Datei. Erstelle die Datei mit \texttt{nano /opt/fitness/docker-compose.yml}: \begin{lstlisting}[language=YAML, caption={Vollständige docker-compose.yml}] services: # ============================================ # REVERSE PROXY (nimmt HTTP/HTTPS-Anfragen entgegen) # ============================================ nginx-proxy: image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: unless-stopped ports: - ''80:80'' - ''443:443'' volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - certs:/etc/nginx/certs - vhost:/etc/nginx/vhost.d - html:/usr/share/nginx/html networks: - proxy-net # ============================================ # SSL-ZERTIFIKATE (automatisch via Let's Encrypt) # ============================================ acme-companion: image: nginxproxy/acme-companion container_name: acme-companion restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - certs:/etc/nginx/certs - vhost:/etc/nginx/vhost.d - html:/usr/share/nginx/html - acme:/etc/acme.sh environment: - DEFAULT_EMAIL=robert@robre.de - NGINX_PROXY_CONTAINER=nginx-proxy depends_on: - nginx-proxy networks: - proxy-net # ============================================ # BACKEND (.NET 8 API) # ============================================ backend: image: fitness-api:latest container_name: fitness-api restart: unless-stopped volumes: - fitness-data:/app/data networks: - proxy-net # ============================================ # FRONTEND (React + Nginx) # ============================================ frontend: image: fitness-web:latest container_name: fitness-web restart: unless-stopped environment: - VIRTUAL_HOST=robre.de,www.robre.de - LETSENCRYPT_HOST=robre.de,www.robre.de - VIRTUAL_PORT=80 depends_on: - backend networks: - proxy-net # ============================================ # VOLUMES (persistente Datenspeicher) # ============================================ volumes: certs: vhost: html: acme: fitness-data: # ============================================ # NETZWERKE # ============================================ networks: proxy-net: driver: bridge \end{lstlisting} \textbf{Zeile für Zeile erklärt:} \paragraph{nginx-proxy} \begin{itemize} \item \texttt{image: nginxproxy/nginx-proxy} – Offizielles Image des nginx-proxy-Projekts. \item \texttt{ports: ''80:80'' / ''443:443''} – Nur dieser Container lauscht auf den Standard-Webports. Alle HTTP/HTTPS-Anfragen kommen hier an. \item \texttt{/var/run/docker.sock:/tmp/docker.sock:ro} – Bindet den Docker-Socket ein (read-only). Darüber erkennt nginx-proxy, welche Container gerade laufen und welche Domains sie bedienen. \item \texttt{certs:/etc/nginx/certs} – Hier speichert der acme-companion die SSL-Zertifikate. \end{itemize} \paragraph{acme-companion} \begin{itemize} \item \texttt{DEFAULT\_EMAIL} – Für Let's-Encrypt-Benachrichtigungen (z. B. wenn ein Zertifikat abläuft). \textbf{Muss eine echte, erreichbare E-Mail-Adresse sein!} \item \texttt{NGINX\_PROXY\_CONTAINER=nginx-proxy} – Sagt dem acme-companion explizit, wie der Proxy-Container heißt. Notwendig, wenn der Container-Name vom Standard abweicht. \item \texttt{depends\_on: nginx-proxy} – Stellt sicher, dass der Proxy zuerst startet. \end{itemize} \paragraph{frontend (entscheidende Umgebungsvariablen!)} \begin{itemize} \item \texttt{VIRTUAL\_HOST=robre.de,www.robre.de} – Sagt nginx-proxy: ''Ich bin für diese Domains zuständig''. Alle Anfragen an \texttt{robre.de} oder \texttt{www.robre.de} werden an diesen Container weitergeleitet. \item \texttt{LETSENCRYPT\_HOST=robre.de,www.robre.de} – Sagt acme-companion: ''Hol SSL-Zertifikate für diese Domains''. \item \texttt{VIRTUAL\_PORT=80} – Der interne Port, auf dem der Container lauscht (unser Nginx im Frontend-Container). \end{itemize} \paragraph{Docker Volumes} \begin{itemize} \item \texttt{certs, vhost, html, acme} – Speichern SSL-Zertifikate, Konfiguration und Challenge-Dateien. Diese Volumes werden von nginx-proxy und acme-companion geteilt. \item \texttt{fitness-data} – Persistente Speicherung der SQLite-Datenbank (siehe Schritt 03). \end{itemize} \subsubsection{Schritt 4: Container starten} \begin{lstlisting}[language=Bash, caption={Alle Container im Hintergrund starten}] cd /opt/fitness docker compose up -d \end{lstlisting} \texttt{-d} steht für \textbf{detached} – die Container laufen im Hintergrund und blockieren nicht das Terminal. \subsubsection{Schritt 5: Status prüfen} \begin{lstlisting}[language=Bash, caption={Laufende Container anzeigen}] docker ps \end{lstlisting} Es sollten vier Container erscheinen: \texttt{nginx-proxy}, \texttt{acme-companion}, \texttt{fitness-api}, \texttt{fitness-web}. \subsubsection{Schritt 6: Logs des acme-companion prüfen} \begin{lstlisting}[language=Bash, caption={SSL-Zertifikatserstellung verfolgen}] docker logs -f acme-companion \end{lstlisting} Bei erfolgreicher Zertifikatserstellung erscheinen diese Meldungen: \begin{lstlisting}[language=Bash, caption={Erfolgreiche Ausgabe}] [Thu May 7 09:56:11 UTC 2026] Registered [Thu May 7 09:56:14 UTC 2026] Account update success Creating/renewal robre.de certificates... (robre.de www.robre.de) \end{lstlisting} \subsection{Häufige Fehler und ihre Behebung} \subsubsection{Fehler 1: ''can't get nginx-proxy container ID''} \textbf{Ursache:} Der acme-companion findet den Proxy-Container nicht, weil dieser einen individuellen Namen hat (\texttt{nginx-proxy} statt dem Standard). \textbf{Lösung:} Die Umgebungsvariable \texttt{NGINX\_PROXY\_CONTAINER=nginx-proxy} im acme-companion setzen (wie oben bereits eingebaut). \subsubsection{Fehler 2: ''contact email has forbidden domain''} \textbf{Ursache:} Die \texttt{DEFAULT\_EMAIL} enthält eine ungültige Domain wie \texttt{@example.com} – Let's Encrypt lehnt reservierte Domains ab. \textbf{Lösung:} Eine echte, erreichbare E-Mail-Adresse verwenden (z. B. \texttt{robert@robre.de}). \subsubsection{Fehler 3: ''DNS problem: NXDOMAIN''} \textbf{Ursache:} Die DNS-Propagation ist noch nicht abgeschlossen. Let's Encrypt kann die Domain nicht auflösen und verweigert die Zertifikatsausstellung. \textbf{Lösung:} Warten, bis die Domain weltweit erreichbar ist (bis zu 24 Stunden). Dann den acme-companion neustarten: \begin{lstlisting}[language=Bash, caption={Zertifikatserstellung erneut anstoßen}] docker restart acme-companion \end{lstlisting} \subsection{Wie füge ich später weitere Subdomains hinzu?} Das System ist extrem flexibel. Angenommen, du willst später eine Todo-App unter \texttt{todo.robre.de} hosten: \begin{enumerate} \item \textbf{DNS-Eintrag beim Hoster:} Einen neuen A-Record für \texttt{todo.robre.de} anlegen, der auf dieselbe Server-IP \texttt{185.209.229.167} zeigt. Oder: Der Wildcard-Eintrag \texttt{*.robre.de} fängt automatisch alle nicht explizit definierten Subdomains ab! \item \textbf{Docker-Container:} Einen neuen Container mit den Umgebungsvariablen \texttt{VIRTUAL\_HOST=todo.robre.de} und \texttt{LETSENCRYPT\_HOST=todo.robre.de} starten. \item \textbf{Fertig!} nginx-proxy erkennt den neuen Container automatisch und leitet Anfragen weiter. acme-companion holt automatisch ein SSL-Zertifikat. \end{enumerate} \textbf{Du musst auf dem Server nichts weiter konfigurieren – alles läuft automatisch!} \subsection{Wie funktioniert der Reverse Proxy im Detail?} Wenn eine Anfrage an \texttt{https://robre.de/api/workouts} eingeht: \begin{enumerate} \item Die Anfrage kommt an Port 443 des Servers an (HTTPS). \item nginx-proxy nimmt die Anfrage entgegen und entschlüsselt sie mit dem SSL-Zertifikat. \item nginx-proxy schaut in den \texttt{Host}-Header: \texttt{robre.de}. \item In seiner Konfiguration findet er: \texttt{robre.de} → Container \texttt{fitness-web}, Port 80. \item Er leitet die Anfrage an \texttt{fitness-web:80} weiter (internes Docker-Netzwerk). \item Der Nginx im Frontend-Container empfängt die Anfrage an \texttt{/api/workouts}. \item Seine Konfiguration sagt: Alles mit \texttt{/api/} → weiterleiten an \texttt{fitness-api:5000}. \item Das Backend verarbeitet die Anfrage und schickt die Antwort zurück durch die gesamte Kette. \end{enumerate} \textbf{Die gesamte Kommunikation innerhalb des Docker-Netzwerks (Schritte 5-8) läuft unverschlüsselt – das ist okay, weil sie den Server nie verlässt.} \subsection{Zusammenfassung} Nach diesem Schritt haben wir: \begin{itemize} \item Einen automatischen Reverse Proxy (nginx-proxy), der Anfragen an die richtigen Container verteilt \item Kostenlose SSL-Zertifikate von Let's Encrypt, die sich automatisch erneuern \item Eine Infrastruktur, die beliebig viele weitere Domains und Subdomains aufnehmen kann – ohne manuelle Konfiguration \item Die App ist unter \texttt{https://robre.de} verschlüsselt erreichbar \item Alle Voraussetzungen für die PWA-Installation sind erfüllt \end{itemize} \textbf{Wartezeit beachten:} Nach der Einrichtung kann es bis zu 24 Stunden dauern, bis die DNS-Propagation abgeschlossen ist und Let's Encrypt die Zertifikate ausstellen kann. Der acme-companion versucht es automatisch jede Stunde erneut.