Multi-Stage-Dockerfile: minimales, sicheres Image für Node und .NET
Build-Werkzeuge gehören nicht ins Produktions-Image. Multi-Stage-Builds trennen Compiler von Laufzeit und schrumpfen Images von über einem Gigabyte auf einen Bruchteil.
Ein frisches docker build eines Node- oder .NET-Projekts ohne weitere Optimierung ergibt häufig ein Image von über einem Gigabyte – vollgestopft mit SDK, Compiler, Dev-Dependencies und temporären Build-Artefakten, die zur Laufzeit niemand braucht. Multi-Stage-Builds trennen den Build-Prozess vom finalen Image: Sie nutzen ein vollständiges Build-Environment nur im Zwischenschritt, und das fertige Image enthält ausschließlich die Laufzeit-Artefakte. Das Ergebnis ist ein deutlich kleineres Image mit geringerer Angriffsfläche.
Wann Multi-Stage-Builds sinnvoll sind
Immer dann, wenn zwischen Build-Werkzeug und Laufzeit-Werkzeug ein Unterschied besteht. Bei kompilierten Sprachen (.NET, Go, Rust) ist das grundsätzlich so; bei JavaScript-Projekten mit TypeScript-Compilation oder Bundle-Schritt gilt dasselbe. Aber auch reines JavaScript profitiert davon, wenn Dev-Dependencies wie Linter, Test-Frameworks oder Build-Tools nicht ins Produktions-Image sollen. Kurz: Wer mehr als FROM node:latest + COPY . . macht, sollte Multi-Stage in Betracht ziehen.
Konzept: Build-Stage vs. Runtime-Stage
Ein Multi-Stage-Dockerfile enthält mehrere FROM-Anweisungen. Jede eröffnet eine neue Stage, die einen eigenen Namen über AS <name> bekommt. Aus einer früheren Stage lässt sich mit COPY --from=<name> gezielt kopieren – der Rest der Stage landet nicht im finalen Image.
Source-Code
|
| docker build
v
+---------------------------+
| Build-Stage | SDK + alle Abhaengigkeiten
| sdk:10.0 / node:24- | npm ci / dotnet restore
| alpine | npm run build / dotnet publish
+---------------------------+
|
| COPY --from=build
v
+---------------------------+
| Runtime-Stage | nur Laufzeit-Artefakte
| aspnet:10.0 / | kein Compiler, kein SDK
| node:24-alpine | keine Dev-Dependencies
+---------------------------+
|
v
Finales Image
docker images zeigt nur das Ergebnis der letzten Stage. Die Build-Stage wird intern verwendet und anschließend verworfen.
Node.js: von einem Gigabyte auf rund 200 MB
Als Beispiel dient eine TypeScript-Anwendung, die mit npm run build nach dist/ kompiliert wird. Legen Sie zunächst eine .dockerignore-Datei im Projektverzeichnis an – ohne sie schickt Docker node_modules und den gesamten Build-Output bei jedem Aufruf mit, was den Build unnötig verlangsamt:
node_modules
dist
.env
.git
coverage
*.log
Das zugehörige Dockerfile:
# ---- Build-Stage ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime-Stage ----
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Zwei Punkte sind entscheidend: Erstens werden package*.json vor dem Quellcode kopiert, damit Docker den npm ci-Layer cacht, solange sich die Abhängigkeiten nicht ändern. Ändern Sie nur eine TypeScript-Datei, überspringt Docker das npm ci und baut sofort weiter – das spart Minuten. Zweitens installiert npm ci --omit=dev in der Runtime-Stage ausschließlich Produktions-Abhängigkeiten; TypeScript-Compiler, Linter und Test-Frameworks fehlen im finalen Image. Das Ergebnis liegt typischerweise bei 180–250 MB statt über einem Gigabyte – das Basis-Image node:24-alpine allein ist bereits rund 160 MB groß.
.NET: SDK-Image vs. Runtime-Image
Für eine ASP.NET Core Minimal API trennen Sie das vollständige SDK-Image für den Build vom deutlich kleineren Runtime-Image für die Auslieferung. Auch hier beginnt die .dockerignore:
bin
obj
.git
.env
**/*.user
Das Dockerfile:
# ---- Build-Stage ----
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# ---- Runtime-Stage ----
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
Das Muster ist analog zu Node: *.csproj kommt zuerst, dotnet restore läuft separat und wird gecacht. dotnet publish -c Release kompiliert und optimiert; --no-restore überspringt ein erneutes Restore, da die Pakete bereits im Layer davor wiederhergestellt wurden. Das aspnet:10.0-Image (Ubuntu 24.04) enthält nur die ASP.NET Core Runtime, kein SDK – das fertige Image ist rund 230–240 MB groß. Wer noch weiter sparen möchte, kann die Chiseled-Variante verwenden (mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled), die auf rund 125 MB schrumpft – etwa die Hälfte des regulären Images.
Hinweis zu Port 8080: Seit .NET 8 hört ASP.NET Core in Container-Umgebungen standardmäßig auf Port 8080, gesteuert über ASPNETCORE_HTTP_PORTS. Diese Variable ist im Basis-Image bereits gesetzt – Sie müssen nichts weiter konfigurieren. USER app funktioniert, weil die offiziellen .NET 8+-Images einen vordefinierten Nicht-Root-Nutzer app (UID 1654) mitbringen.
Ersetzen Sie MyApi.dll durch den tatsächlichen Assembly-Namen Ihres Projekts (im Zweifel: entspricht dem <AssemblyName> in der .csproj-Datei).
Drei typische Stolperfallen
1. Alpine und native Module. node:24-alpine basiert auf musl libc, nicht auf glibc. Viele populäre native Module liefern inzwischen fertige musl-Binaries mit und installieren auf Alpine ohne Kompilierung – etwa bcrypt (ab v6), sharp (ab v0.33) und sqlite3. Ältere Versionen oder Module ohne musl-Prebuilds, die über node-gyp aus dem Quellcode bauen, benötigen dagegen apk add python3 make g++ in der Build-Stage. Wenn das aufwändig ist, wechseln Sie zu node:24-slim (Debian-basiert, glibc vorhanden).
2. .dockerignore vergessen. Ohne .dockerignore überträgt Docker den gesamten Build-Kontext inklusive node_modules oder bin/obj an den Build-Daemon – je nach Projektgröße kostet das viele Sekunden bis Minuten. Die Datei sollte vor dem ersten docker build im Projektverzeichnis liegen.
3. latest statt gepinnter Tags. FROM node:latest oder FROM mcr.microsoft.com/dotnet/sdk:latest kann bei jedem Rebuild eine andere Version liefern. Pinnen Sie auf konkrete Tags wie node:24-alpine oder sdk:10.0, damit Builds reproduzierbar bleiben.
Wie es weitergeht
Das Muster ist direkt auf andere Sprachen übertragbar: Go-Binaries, Python-Wheels, Rust-Executables – überall gilt dieselbe Logik. Als nächste Schritte bieten sich an: das fertige Image mit docker scout cves oder Trivy auf bekannte Schwachstellen prüfen, einen GitHub-Actions-Workflow einrichten, der das Image automatisch auf die GitHub Container Registry (GHCR) schiebt, oder die Chiseled-Variante für .NET testen.
Offizielle Dokumentation: Docker Multi-Stage Builds, Docker Build Best Practices, Node.js Official Docker Images, .NET Container Images.
Hinweis: Die Beiträge dieses Blogs werden unter Einsatz von KI erstellt und vor der Veröffentlichung redaktionell geprüft. Die redaktionelle Verantwortung trägt Emre Yurtbay (siehe Impressum).