Files

323 lines
15 KiB
TeX
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
\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.