% ============================================ % STEP 03: DOCKER, DEPLOYMENT & ERSTE LIVE-APP % ============================================ \section{Docker-Images bauen und App deployen} \label{sec:step03} In diesem Schritt bringen wir unsere Fitness-App vom lokalen Entwicklungsrechner auf den Server und machen sie weltweit erreichbar. Dafür nutzen wir \textbf{Docker} – eine Container-Plattform, die Anwendungen in standardisierten, isolierten Umgebungen verpackt und ausführt. \subsection{Was ist Docker und warum nutzen wir es?} Docker funktioniert wie ein \textbf{Versandkarton für Software}. Stell dir vor, du verschickst ein zerbrechliches Paket: Du packst es in einen genormten Karton, der überall auf der Welt gleich behandelt wird – egal ob in Deutschland, Japan oder Brasilien. Docker macht dasselbe mit Software: \begin{itemize} \item \textbf{Image} = Der Bauplan des Kartons (inkl. Inhalt). Ein Image enthält das Betriebssystem, alle Abhängigkeiten und die Anwendung selbst. \item \textbf{Container} = Der tatsächliche laufende Karton. Ein Container ist eine laufende Instanz eines Images. \item \textbf{Volume} = Ein separater Speicherort, der den Container überlebt. Wie ein externer USB-Stick, den man an den Karton anschließt. \end{itemize} \textbf{Konkret für unser Projekt:} \begin{itemize} \item \texttt{fitness-api:latest} – Image mit .NET 8 Backend \item \texttt{fitness-web:latest} – Image mit React Frontend + Nginx \item \texttt{fitness-data} – Volume für die SQLite-Datenbank (überlebt Container-Neustarts) \end{itemize} \subsection{Die drei Dockerfiles im Detail} \subsubsection{Backend-Dockerfile: \texttt{apps/api/Dockerfile}} \begin{lstlisting}[language=Dockerfile, caption={Dockerfile für das .NET Backend}] FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["apps/api/Api.csproj", "apps/api/"] RUN dotnet restore "apps/api/Api.csproj" COPY . . WORKDIR /src/apps/api RUN dotnet publish "Api.csproj" -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app COPY --from=build /app/publish . EXPOSE 5000 ENV ASPNETCORE_URLS=http://+:5000 ENTRYPOINT ["dotnet", "Api.dll"] \end{lstlisting} \textbf{Zeile für Zeile erklärt:} \begin{enumerate} \item \texttt{FROM ... AS build} – Startet mit dem .NET 8 SDK Image (enthält Compiler, Tools). Der Alias \texttt{build} erlaubt später darauf zuzugreifen. \item \texttt{WORKDIR /src} – Setzt das Arbeitsverzeichnis im Container auf \texttt{/src}. \item \texttt{COPY ["apps/api/Api.csproj", "apps/api/"]} – Kopiert NUR die Projektdatei. Dadurch cached Docker diesen Schritt: Solange sich \texttt{Api.csproj} nicht ändert, wird der Cache verwendet $\rightarrow$ schnellere Builds! \item \texttt{RUN dotnet restore} – Lädt alle NuGet-Pakete herunter (Entity Framework, NSwag, Swagger usw.). \item \texttt{COPY . .} – Kopiert den gesamten restlichen Quellcode. \item \texttt{WORKDIR /src/apps/api} – Wechselt ins Backend-Verzeichnis. \item \texttt{RUN dotnet publish} – Kompiliert die Anwendung im Release-Modus in den Ordner \texttt{/app/publish}. \item \texttt{FROM ... AS final} – Startet ein NEUES, schlankeres Image (nur ASP.NET Runtime, kein SDK). Das spart Speicher! \item \texttt{COPY --from=build ...} – Kopiert die kompilierte Anwendung aus dem Build-Image. \item \texttt{EXPOSE 5000} – Dokumentiert, dass der Container auf Port 5000 lauscht. \item \texttt{ENV ASPNETCORE\_URLS=http://+:5000} – Sagt .NET, es soll auf Port 5000 auf ALLEN Netzwerkschnittstellen lauschen. \item \texttt{ENTRYPOINT ["dotnet", "Api.dll"]} – Startet die Anwendung beim Container-Start. \end{enumerate} \subsubsection{Frontend-Dockerfile: \texttt{apps/web/Dockerfile}} \begin{lstlisting}[language=Dockerfile, caption={Dockerfile für das React Frontend}] FROM node:22-alpine AS build WORKDIR /app COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/web/package.json apps/web/ RUN npm install -g pnpm && pnpm install --no-frozen-lockfile COPY apps/web/ apps/web/ WORKDIR /app/apps/web RUN pnpm run build FROM nginx:stable-alpine COPY --from=build /app/apps/web/dist /usr/share/nginx/html COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \end{lstlisting} \textbf{Zeile für Zeile erklärt:} \begin{enumerate} \item \texttt{FROM node:22-alpine} – Leichtgewichtiges Node.js 22 Image (Alpine Linux = nur $\sim$5 MB statt $\sim$180 MB bei Ubuntu). \item \texttt{COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./} – Kopiert die Monorepo-Konfiguration aus dem Root. \texttt{pnpm-workspace.yaml} ist nötig, damit pnpm die Workspace-Struktur erkennt. \item \texttt{COPY apps/web/package.json apps/web/} – Kopiert die Frontend-Paketliste an ihren Platz. \item \texttt{RUN npm install -g pnpm \&\& pnpm install} – Installiert pnpm global und dann alle Abhängigkeiten. \item \texttt{COPY apps/web/ apps/web/} – Kopiert den restlichen Frontend-Code. \item \texttt{WORKDIR /app/apps/web} – Wechselt ins Frontend-Verzeichnis. \item \texttt{RUN pnpm run build} – Baut das Frontend mit Vite (erzeugt \texttt{dist/}). \item \texttt{FROM nginx:stable-alpine} – NEUES schlankes Image mit Nginx (Webserver). \item \texttt{COPY --from=build ... /usr/share/nginx/html} – Kopiert den Build-Output in Nginx's Standard-Webverzeichnis. \item \texttt{COPY apps/web/nginx.conf ...} – Unsere eigene Nginx-Konfiguration. \item \texttt{EXPOSE 80} – HTTP-Port. \item \texttt{CMD ["nginx", "-g", "daemon off;"]} – Startet Nginx im Vordergrund (Container bleibt am Leben). \end{enumerate} \subsubsection{Nginx-Konfiguration: \texttt{apps/web/nginx.conf}} \begin{lstlisting}[language=Bash, caption={Nginx-Konfiguration mit Reverse Proxy}] server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://fitness-api:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } } \end{lstlisting} \textbf{Erklärung:} Dies ist ein \textbf{Reverse Proxy}. Nginx nimmt alle Anfragen entgegen und entscheidet, wohin sie weitergeleitet werden: \begin{itemize} \item \texttt{location /} – Anfragen an die Hauptseite $\rightarrow$ liefert React-Dateien aus \texttt{/usr/share/nginx/html} \item \texttt{location /api/} – Anfragen an \texttt{/api/*} $\rightarrow$ leitet sie an das Backend (\texttt{fitness-api:5000}) weiter \item \texttt{try\_files \$uri \$uri/ /index.html} – Sorgt dafür, dass Reacts Client-Side-Routing funktioniert (z.B. \texttt{/workouts/123} wird an React weitergegeben, nicht als 404 beantwortet) \end{itemize} \subsection{Das Backend: Program.cs im Detail} \begin{lstlisting}[language=CSharp, caption={Vollständige Program.cs}] using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); var dbPath = Path.Combine("/app/data", "fitness.db"); builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApiDocument(); builder.Services.AddSwaggerGen(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); } if (app.Environment.IsDevelopment()) { app.UseOpenApi(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.MapPost("/api/workouts", async (Workout workout, AppDbContext db) => { ... }); app.MapGet("/api/workouts", async (AppDbContext db) => ...); app.MapGet("/api/workouts/{id:guid}", async (Guid id, AppDbContext db) => ...); app.MapPut("/api/workouts/{id:guid}", async (Guid id, Workout input, AppDbContext db) => ...); app.MapDelete("/api/workouts/{id:guid}", async (Guid id, AppDbContext db) => ...); app.MapGet("/", () => "H Fitness API läuft!"); app.Run(); \end{lstlisting} \textbf{Die NuGet-Pakete in der .csproj-Datei:} \begin{itemize} \item \texttt{Microsoft.EntityFrameworkCore.Sqlite} (8.0.14) – EF Core für SQLite \item \texttt{Microsoft.EntityFrameworkCore.Design} (8.0.14) – EF Core Tools für Migrationen \item \texttt{NSwag.AspNetCore} (14.1.0) – OpenAPI/Swagger-Generator \item \texttt{Swashbuckle.AspNetCore} (6.6.2) – Swagger UI (die schöne Oberfläche) \end{itemize} \textbf{Wichtige Details:} \begin{itemize} \item \texttt{Path.Combine("/app/data", "fitness.db")} – Im Docker-Container liegt die Datenbank im Volume-Ordner \texttt{/app/data}. Das stellt sicher, dass die Daten erhalten bleiben, auch wenn der Container gelöscht und neu erstellt wird. \item \texttt{db.Database.EnsureCreated()} – Erstellt die Datenbank-Tabellen automatisch beim Start, falls sie noch nicht existieren. Erspart uns manuelle Migrationen im Produktivbetrieb. \item Die CRUD-Endpunkte sind \textbf{Minimal API Endpoints} – .NET 8's leichtgewichtige Alternative zu Controllern. \end{itemize} \subsection{Der API-Client: client.ts im Detail} \begin{lstlisting}[language=TypeScript, caption={Manueller API-Client}] const API_BASE = ""; export interface Workout { id?: string; name: string; date: string; durationMinutes: number; notes?: string; } export const fitnessApi = { async getWorkouts(): Promise { const res = await fetch(`/api/workouts`); return res.json(); }, async createWorkout(workout: Workout): Promise { const res = await fetch(`/api/workouts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(workout), }); return res.json(); }, // Weitere Methoden: getWorkout, updateWorkout, deleteWorkout... }; \end{lstlisting} \textbf{Erklärung:} \begin{itemize} \item \texttt{API\_BASE = ""} – Keine absolute URL! Stattdessen relative Pfade wie \texttt{/api/workouts}. Der Browser sendet die Anfrage dann an dieselbe Domain, auf der die Seite gehostet ist. Nginx leitet sie an das Backend weiter. \item \texttt{interface Workout} – TypeScript-Interface für Typsicherheit. Stellt sicher, dass wir keine falschen Felder an die API senden. \item \texttt{fetch()} – Native Browser-API für HTTP-Anfragen. Kein Axios, kein jQuery nötig! \item Die Methoden geben direkt das geparste JSON zurück. \end{itemize} \subsection{Das Frontend: App.tsx im Detail} \begin{lstlisting}[language=TypeScript, caption={Hauptkomponente mit Workout-Liste und Formular}] function App() { const [workouts, setWorkouts] = useState([]); const [form, setForm] = useState({ ... }); const loadWorkouts = async () => { const data = await fitnessApi.getWorkouts(); setWorkouts(data); }; useEffect(() => { loadWorkouts(); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await fitnessApi.createWorkout({ ...form }); loadWorkouts(); }; return (
{/* Formular */}
...
{/* Workout-Liste */} {workouts.map(w => ( ... ))}
); } \end{lstlisting} \textbf{Erklärung:} \begin{itemize} \item \texttt{useState} – Reacts State-Management für die Workout-Liste und das Formular. \item \texttt{useEffect} – Lädt die Workouts einmalig beim ersten Rendern der Komponente. \item \texttt{handleSubmit} – Wird beim Absenden des Formulars aufgerufen. Verhindert den Standard-Seiten-Reload (\texttt{e.preventDefault()}), sendet das Workout an die API und lädt die Liste neu. \item Tailwind CSS-Klassen wie \texttt{bg-gray-950}, \texttt{text-white}, \texttt{p-4} gestalten die App im Dark-Mode. \end{itemize} \subsection{Images bauen} Am Anfang war das Frontend-Image fehlerhaft. Hier die drei wichtigsten Fixes: \textbf{Fehler 1: \texttt{pnpm-lock.yaml} nicht gefunden} \begin{itemize} \item Ursache: Dockerfile suchte in \texttt{apps/web/}, aber die Datei liegt im Root. \item Lösung: \texttt{COPY pnpm-lock.yaml ./} (vom Root kopieren). \end{itemize} \textbf{Fehler 2: \texttt{--frozen-lockfile} schlug fehl} \begin{itemize} \item Ursache: pnpm-Lockfile war nicht aktuell mit der Root-\texttt{package.json}. \item Lösung: \texttt{--no-frozen-lockfile} verwenden, damit pnpm fehlende Pakete nachinstalliert. \end{itemize} \textbf{Fehler 3: TypeScript Compiler (\texttt{tsc}) nicht gefunden} \begin{itemize} \item Ursache: \texttt{tsc -b} benötigt TypeScript als Abhängigkeit, die im Container fehlte. \item Lösung: Build-Script von \texttt{"tsc -b \&\& vite build"} auf \texttt{"vite build"} geändert. Vite führt den TypeScript-Check beim Dev-Server durch – im Produktions-Build reicht die reine Vite-Kompilierung. \end{itemize} \subsection{Images exportieren und auf den Server kopieren} \begin{lstlisting}[language=Bash, caption={Images exportieren und kopieren}] # Images als tar-Datei speichern docker save fitness-api:latest fitness-web:latest -o fitness-images.tar # Auf den Server kopieren (scp = Secure Copy über SSH) scp fitness-images.tar testserver:/root/ # Auf dem Server importieren ssh testserver docker load -i /root/fitness-images.tar docker images | grep fitness \end{lstlisting} \textbf{Befehle erklärt:} \begin{itemize} \item \texttt{docker save} – Exportiert Docker-Images in eine portable tar-Datei. \item \texttt{scp} – Secure Copy: Kopiert Dateien verschlüsselt über SSH. \item \texttt{testserver:/root/} – Der Alias aus unserer \texttt{\textasciitilde/.ssh/config}. Die Datei landet im \texttt{/root/}-Verzeichnis des Servers. \item \texttt{docker load -i} – Importiert Images aus einer tar-Datei in Docker. \item \texttt{docker images} – Listet alle lokal verfügbaren Docker-Images auf. \end{itemize} \subsection{Container auf dem Server starten} \begin{lstlisting}[language=Bash, caption={Docker-Netzwerk, Volume und Container anlegen}] # Volume für die Datenbank (überlebt Container-Neustarts) docker volume create fitness-data # Netzwerk (damit Backend und Frontend kommunizieren können) docker network create fitness-net # Backend starten docker run -d \ --name fitness-api \ --network fitness-net \ -v fitness-data:/app/data \ -p 5000:5000 \ fitness-api:latest # Frontend starten docker run -d \ --name fitness-web \ --network fitness-net \ -p 80:80 \ fitness-web:latest \end{lstlisting} \textbf{Optionen erklärt:} \begin{itemize} \item \texttt{-d} – Detached Mode: Container läuft im Hintergrund (gibt die Konsole frei). \item \texttt{--name fitness-api} – Gibt dem Container einen festen Namen (sonst vergibt Docker Zufallsnamen). \item \texttt{--network fitness-net} – Bindet den Container in unser Docker-Netzwerk ein. Container im selben Netzwerk können sich über ihren Namen erreichen (z.B. \texttt{fitness-api}). \item \texttt{-v fitness-data:/app/data} – Bindet das Volume \texttt{fitness-data} in den Container-Pfad \texttt{/app/data} ein. Alles, was im Container unter \texttt{/app/data} gespeichert wird, landet tatsächlich im Volume und überlebt. \item \texttt{-p 5000:5000} – Port-Mapping: Leitet Port 5000 des Hosts (Server) an Port 5000 des Containers weiter. \end{itemize} \subsection{Aufgetretene Probleme und Lösungen} \textbf{Problem 1: Nginx konnte Host "backend" nicht auflösen} \begin{itemize} \item Ursache: In \texttt{nginx.conf} stand \texttt{proxy\_pass http://backend:5000}, aber der Backend-Container heißt \texttt{fitness-api}. \item Lösung: \texttt{backend} $\rightarrow$ \texttt{fitness-api} in \texttt{nginx.conf} ändern, Image neu bauen. \end{itemize} \textbf{Problem 2: Frontend lud Assets mit Pfad \texttt{/app/...}} \begin{itemize} \item Ursache: In \texttt{vite.config.ts} war \texttt{base: "/app/"} für PWA-Zwecke gesetzt. \item Lösung: Geändert auf \texttt{base: "/"}. \end{itemize} \textbf{Problem 3: API-Anfragen gingen an lokale IP \texttt{192.168.178.189}} \begin{itemize} \item Ursache: Im Client stand \texttt{const API\_BASE = "http://192.168.178.189:5107"}. \item Lösung: Geändert auf relative Pfade (\texttt{/api/workouts}), sodass Nginx die Anfragen per Reverse Proxy ans Backend weiterleitet. \end{itemize} \textbf{Problem 4: Doppeltes \texttt{/api} im Pfad} \begin{itemize} \item Ursache: Client hatte \texttt{API\_BASE = "/api"} und Endpunkte begannen ebenfalls mit \texttt{/api}. \item Ergebnis: Anfragen gingen an \texttt{/api/api/workouts}. \item Lösung: API-Base auf leeren String gesetzt und Endpunkte mit \texttt{/api/} beginnen lassen. \end{itemize} \subsection{Zusammenfassung} Nach diesem Schritt ist die Fitness-App produktiv auf dem VPS im Einsatz: \begin{itemize} \item Die App ist unter \texttt{http://185.209.229.167} weltweit erreichbar \item Workouts werden persistent in einer SQLite-Datenbank gespeichert \item Docker-Volumes stellen sicher, dass Daten Container-Neustarts überleben \item Das Backend läuft auf .NET 8, das Frontend auf Nginx \item Ein Reverse Proxy (Nginx) leitet API-Anfragen intern an das Backend weiter \end{itemize}