diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..4aaca97
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+# Der Bau-Kontext ist die Wurzel des Projekts, seit die Oberfläche
+# mitgebaut wird. Diese Datei gehört deshalb hierher und nicht nach server/ —
+# Docker sucht sie neben dem Kontext, nicht neben dem Dockerfile.
+
+# **Der Grund für diese Datei.** Ohne sie überschreibt `COPY server/ ./server/`
+# die obj/-Verzeichnisse, die `dotnet restore` im Container gerade angelegt
+# hat, mit denen vom Arbeitsrechner. Die zeigen auf osx-arm64, der Bau will
+# linux-arm64, und `dotnet publish` bricht mit NETSDK1047 ab.
+**/bin/
+**/obj/
+**/*.user
+
+# Gebaute Oberfläche und ihre Abhängigkeiten: der web-Abschnitt baut beides neu.
+**/node_modules/
+web/app/dist/
+
+# Laufzeitdaten eines lokalen Starts.
+server/data/
+server/cache/
+server/transcode/
+
+# Was im Server-Image nichts verloren hat.
+.git/
+.build/
+DerivedData/
+*.xcodeproj/
diff --git a/server/.dockerignore b/server/.dockerignore
deleted file mode 100644
index 2e264a2..0000000
--- a/server/.dockerignore
+++ /dev/null
@@ -1,6 +0,0 @@
-**/bin/
-**/obj/
-**/*.user
-data/
-cache/
-transcode/
diff --git a/server/Dockerfile b/server/Dockerfile
index 14d8cdf..a94b1de 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,5 +1,15 @@
# syntax=docker/dockerfile:1
+# Die Oberfläche zuerst, in einem eigenen Abschnitt: sie braucht Node, der
+# Server nicht. So landet Node nie im fertigen Image.
+FROM --platform=$BUILDPLATFORM node:22-alpine AS web
+WORKDIR /web
+COPY web/app/package.json web/app/package-lock.json ./
+RUN npm ci
+COPY web/app/ ./
+RUN npm run build
+
+
# Gebaut wird auf der Architektur des Bauwirts, übersetzt wird für die des
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
# arm64 unter Emulation und dauerte ein Vielfaches.
@@ -13,11 +23,11 @@ WORKDIR /src
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
-COPY --parents src/*/*.csproj ./
-RUN dotnet restore src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
+COPY --parents server/src/*/*.csproj ./
+RUN dotnet restore server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
-COPY . .
-RUN dotnet publish src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
+COPY server/ ./server/
+RUN dotnet publish server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
@@ -65,6 +75,7 @@ ENV FLARO_DATA=/config \
WORKDIR /app
COPY --from=build /app .
+COPY --from=web /web/dist ./wwwroot
EXPOSE 8080
VOLUME ["/config", "/cache", "/transcode"]
diff --git a/server/README.md b/server/README.md
index 70e89f3..5f13bff 100644
--- a/server/README.md
+++ b/server/README.md
@@ -65,10 +65,34 @@ in Software; das ist langsamer, aber es läuft. **Zum blossen Umpacken wird gar
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
hilft.
+## Weboberfläche
+
+Der Server liefert sie selbst aus, unter derselben Adresse wie die
+Schnittstelle. Kein Proxy davor, kein CORS, kein Mischmasch aus http und
+https. Gebaut wird sie im Image mit; wer nur an ihr arbeitet, nimmt Vite:
+
+```bash
+cd ../web/app
+npm install
+FLARO_SERVER=http://localhost:18080 npm run dev # auf http://localhost:5173
+```
+
+Vite reicht `/api` an den Server weiter, damit die Herkunft dieselbe bleibt
+wie im Betrieb.
+
+**Der Zugang liegt in einem Cookie, nicht nur in der Kopfzeile.** Ein
+``, ein `