 
\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.