Add step 03: Docker, Deployment & First Live App

- Introduced a new section on building Docker images and deploying the fitness app.
- Added detailed explanations of Docker concepts, including images, containers, and volumes.
- Included three Dockerfiles for the backend and frontend, with line-by-line explanations.
- Updated main.tex to include step_03.tex.
- Modified main.toc to reflect new sections and subsections.
- Updated main.fls and main.log to include new font inputs and log entries related to the new content.
- Adjusted font configurations for FiraMono and FiraSans in main.fls.
- Updated PDF and synctex files to reflect changes in the document structure.
This commit is contained in:
2026-05-06 17:27:49 +02:00
parent 8a4ed88b93
commit 0e9377739e
9 changed files with 532 additions and 45 deletions
+382
View File
@@ -0,0 +1,382 @@
% ============================================
% 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<AppDbContext>(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<AppDbContext>();
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<Workout[]> {
const res = await fetch(`/api/workouts`);
return res.json();
},
async createWorkout(workout: Workout): Promise<Workout> {
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<Workout[]>([]);
const [form, setForm] = useState<Workout>({ ... });
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 (
<div className="min-h-screen bg-gray-950 text-white p-4 max-w-md mx-auto">
{/* Formular */}
<form onSubmit={handleSubmit}>...</form>
{/* Workout-Liste */}
{workouts.map(w => ( ... ))}
</div>
);
}
\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}