323 lines
15 KiB
TeX
323 lines
15 KiB
TeX
|
||
\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. |