Auf meinem Server liegen zwei Images nebeneinander, die beide aus einem Multi-Stage-Dockerfile stammen. Eine Next.js-Anwendung: 227 MB. Ein Strapi-CMS: 1,03 GB. Daneben die beiden offiziellen .NET-Basis-Images, aus denen sich dasselbe Muster bauen lässt: SDK 940 MB, Laufzeit 230 MB. Gemessen am 3. September 2026 mit docker image inspect --format '{{.Size}}'. Das ist die entpackte Größe auf der Platte, nicht die Downloadgröße aus der Registry.
Aktualisiert am 19. Oktober 2026: Beitrag um nachgemessene Größen eigener Images, den Lockfile-Vorfall mit npm 10 und das Verhalten von Docker-Layern beim Nachinstallieren erweitert.
Aktualisiert am 7. Oktober 2026: Hinweis zur Supply-Chain-Kompromittierung von Trivy im März 2026 ergänzt: Die GitHub Action per Commit-SHA und das Image per Digest pinnen, keine schwimmenden Tags verwenden.
Die Spreizung zwischen 227 MB und 1,03 GB ist der eigentliche Punkt. Beide sind Multi-Stage-Builds. Das Muster allein garantiert kein kleines Image, es garantiert nur, dass Sie entscheiden können, was hineinkommt. Zur Einordnung: Das Basis-Image node:26-alpine wiegt auf demselben Host bereits 174 MB. Von den 227 MB der Next.js-Anwendung entfallen also gut drei Viertel auf die Laufzeit selbst.
Build-Stage und Runtime-Stage
Ein Multi-Stage-Dockerfile enthält mehrere FROM-Anweisungen. Jede eröffnet eine neue Stage, die über AS <name> benannt wird. Aus einer früheren Stage lässt sich mit COPY --from=<name> gezielt kopieren. Alles, was Sie nicht kopieren, landet nicht im Ergebnis.
Source-Code
|
| docker build
v
+---------------------------+
| Build-Stage | SDK + alle Abhaengigkeiten
| sdk:10.0 / node:26- | npm ci / dotnet restore
| alpine | npm run build / dotnet publish
+---------------------------+
|
| COPY --from=build
v
+---------------------------+
| Runtime-Stage | nur Laufzeit-Artefakte
| aspnet:10.0 / node:26- | kein Compiler, kein SDK
| alpine | keine Dev-Dependencies
+---------------------------+
|
v
Finales Image
docker images zeigt nur das Ergebnis der letzten Stage. Sinnvoll ist das überall dort, wo Build-Werkzeug und Laufzeit-Werkzeug sich unterscheiden: bei .NET, Go und Rust immer, bei TypeScript-Projekten ebenso, und bei reinem JavaScript, sobald Linter und Test-Frameworks nicht ins Produktions-Image sollen.
Node.js: die Reihenfolge im Dockerfile entscheidet über den Cache
Das Dockerfile meiner Next.js-Anwendung nutzt vier Stages: eine gemeinsame base und darauf deps, builder, runner. Gekürzt auf das Wesentliche:
FROM node:26-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Entscheidend ist die zweite Zeile der deps-Stage: Nur package.json und package-lock.json werden kopiert, dann läuft npm ci. Ändert sich eine TypeScript-Datei, aber keine Abhängigkeit, bleibt dieser Layer im Cache und der Build springt direkt zum Kompilieren. Kopieren Sie stattdessen zuerst den Quellcode, invalidieren Sie den Installations-Layer bei jeder Änderung.
Dazu gehört zwingend eine .dockerignore im selben Verzeichnis. Ohne sie schickt Docker node_modules und den kompletten Build-Output bei jedem Aufruf an den Daemon:
node_modules
.next
npm-debug.log*
.env*
coverage
.git
Der Fehler, der beide Pipelines rot stehen ließ
Am 23. Juni 2026 standen in meinem Next.js-Projekt beide GitHub-Actions-Pipelines rot, aus zwei verschiedenen Gründen. Die CI-Pipeline scheiterte an npm ci, die Image-Pipeline am nachgelagerten Schwachstellen-Scan. Der erste Fehler ist der lehrreichere.
Für diesen Beitrag habe ich den damaligen Lockfile-Stand aus der Git-Historie geholt und npm ci im Build-Image noch einmal darauf losgelassen. Der Kopf der Ausgabe:
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: @parcel/watcher-android-arm64@2.5.6 from lock file
npm error Missing: @parcel/watcher-darwin-arm64@2.5.6 from lock file
npm error Missing: @parcel/watcher-darwin-x64@2.5.6 from lock file
[... 161 weitere "Missing:"-Zeilen ...]
npm error Missing: @esbuild/win32-x64@0.28.1 from lock file
165 solcher Zeilen, quer durch @parcel/watcher-*, @tailwindcss/oxide-*, @esbuild/*, lightningcss-*, @img/sharp-*, @swc/core-* und @unrs/resolver-binding-*.
Der irritierende Teil: Diese Pakete brauchte niemand. @esbuild/darwin-arm64 ist die macOS-Variante eines Bundlers, @esbuild/win32-x64 die für Windows. Gebaut wurde in einem Linux-Container. Trotzdem verweigerte npm den Dienst.
Ursache war die Art, wie das Lockfile entstanden war: unter npm 10 neu aufgelöst, laut meiner Notiz von damals mit npm install --package-lock-only, der bequeme Weg, wenn man eine Version anheben will, ohne die Pakete lokal zu installieren. Dabei fielen die plattformspezifischen optionalDependencies heraus. Das Build-Image war inzwischen node:26-alpine mit npm 11, und npm 11 verlangt für npm ci, dass alle deklarierten optionalen Plattformvarianten im Lockfile stehen, auch die, die es auf der aktuellen Plattform gar nicht installiert. Fehlen sie, gilt das Lockfile als nicht synchron, und npm ci bricht ab, statt sie nachzuinstallieren. Genau das ist ja der Sinn von npm ci.
Was genau fehlte, lässt sich an den beiden Git-Ständen abzählen. Der kaputte Stand hatte 597 Paket-Einträge, der reparierte 761. Von jeder Familie plattformspezifischer Pakete war in der kaputten Datei genau eine Variante übrig: @esbuild/linux-arm64, @tailwindcss/oxide-linux-arm64-musl, @parcel/watcher-linux-arm64-musl, exakt die der Maschine, auf der die Datei entstanden war. Im reparierten Lockfile (lockfileVersion 3) stehen dagegen 74 @esbuild/-Einträge und 12 @tailwindcss/oxide-*.
Der Fix war zweiteilig. Erstens: Lockfile mit einem echten npm install im Build-Image neu erzeugen. Zweitens: dieselbe npm-Hauptversion überall. Das Build-Image ist node:26-alpine mit npm 11.16.0, die CI-Pipeline wurde im selben Commit von setup-node mit Node 22 auf Node 26 gehoben, auf dem Host läuft npm 11.19.0. Drei Umgebungen, eine Hauptversion, ein Lockfile.
Vorher hatte ich es andersherum versucht, und das ist der Teil, den ich mir gemerkt habe. Keine zwanzig Minuten früher am selben Tag hatte ich das Lockfile bewusst mit npm 10 neu aufgelöst, damit die CI wieder grün wird; die fuhr damals noch Node 22. Genau dieser Stand zerlegte anschließend den Docker-Build unter npm 11. Ein Lockfile kann nicht zwei npm-Hauptversionen gleichzeitig bedienen. Wer an einer Stelle repariert, ohne die anderen mitzuziehen, verschiebt den Fehler nur.
Ein Nachtrag aus der Gegenprobe: Der Auslöser ist versionsabhängig. In einem Wegwerf-Container mit npm 10.9.8 habe ich ein Lockfile per --package-lock-only erzeugt: Dieses späte npm 10 schreibt die Plattformvarianten mit hinein, npm ci unter npm 11 läuft damit durch. Welche npm-10-Patchversion die kaputte Datei damals erzeugt hat, ist nicht dokumentiert. Wenn Sie heute nicht auf die Falle treten, heißt das also nicht, dass Ihr Vorgehen sicher ist. Es heißt, dass Ihre npm-Version sie gerade nicht auslöst.
Die Regel, die bei mir seitdem gilt, lautet deshalb nicht "nie --package-lock-only". Sie lautet: Das Lockfile wird mit dem npm des Build-Images erzeugt, und Host, CI und Build-Image fahren dieselbe npm-Hauptversion. Solange das stimmt, ist auch docker run --rm -v $PWD:/app -w /app node:26-alpine npm install <paket> --package-lock-only unkritisch. So hebe ich Versionen bis heute an. Kippt eine der drei Umgebungen auf eine andere Hauptversion, kippt das Lockfile mit.
Warum rm -rf npm das Image nicht kleiner macht
In der runner-Stage steht seit demselben Tag eine Zeile, die auf den ersten Blick nach Sparsamkeit aussieht:
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
Anlass war der Trivy-Scan in der Image-Pipeline, konfiguriert auf severity: HIGH,CRITICAL, ignore-unfixed: true und exit-code: 1: Ein Fund, für den es einen Fix gibt, lässt den Job scheitern. Der Fund kam nicht aus der Anwendung. Das npm-CLI, das in node:26-alpine mitgeliefert wird, bringt ein eigenes, mitgeliefertes undici in Version 6.26.0 mit; Node 26 selbst benutzt 8.5.0. Nachgesehen im Image: cat /usr/local/lib/node_modules/npm/node_modules/undici/package.json zeigt 6.26.0, node -e "console.log(process.versions.undici)" zeigt 8.5.0. Die Laufzeit ruft nie npm auf, sondern nur node server.js und node scripts/migrate.mjs. Das Werkzeug war reine Angriffsfläche.
Was ich damals als Nebeneffekt notiert hatte, "Image scan-clean + kleiner", stimmt zur Hälfte. Ich habe die beiden Images von damals nebeneinandergelegt: der Stand davor ist 227.376.409 Bytes groß, der Stand danach exakt genauso. Auf die Byte, bei unterschiedlichen Image-IDs. docker history erklärt es: Die RUN rm -rf-Zeile erzeugt einen eigenen Layer der Größe 0 B. Die npm-Dateien liegen im Basis-Layer und bleiben dort; das Löschen legt nur eine Markierung darüber, die sie im zusammengesetzten Dateisystem verschwinden lässt. Im Container ist npm weg (ls /usr/local/bin/npm meldet "No such file or directory", und /usr/local/lib/node_modules schrumpft von 17,8 MB auf 8 KB), auf der Platte nicht.
Für den Scanner reicht das, denn Trivy sieht das zusammengesetzte Dateisystem. Für die Größe bringt es nichts. Wer wirklich Bytes sparen will, muss das Weglassen in die Stage verlegen, in der die Datei entsteht, oder ein schlankeres Basis-Image wählen. Das ist derselbe Grund, aus dem sich Container-Updates nicht durch Löschen im nachgelagerten Layer lösen lassen.
.NET: SDK bauen, Runtime ausliefern
Für eine ASP.NET-Core-Anwendung ist das Muster identisch, nur die Basis-Images heißen anders:
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
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"]
*.csproj zuerst, dotnet restore separat: dieselbe Cache-Logik wie bei npm ci. --no-restore verhindert, dass dotnet publish die Pakete ein zweites Mal auflöst.
Zwei Details, die ich am lokalen Image nachgesehen habe statt sie aus der Doku zu übernehmen. docker image inspect mcr.microsoft.com/dotnet/aspnet:10.0 zeigt in der Umgebung ASPNETCORE_HTTP_PORTS=8080 und APP_UID=1654; im /etc/passwd desselben Images steht app:x:1654:1654::/home/app:/bin/sh. Der Port ist also im Basis-Image gesetzt, der Nicht-Root-Nutzer existiert bereits, und USER app funktioniert ohne eigenes adduser. Zur Größe: SDK 940 MB gegen Laufzeit 230 MB. Eine Einschränkung gehört dazu: Das SDK-Image liegt hier als arm64-Variante, das Laufzeit-Image als amd64. Die Größenordnung trägt, exakt vergleichbar sind die beiden Zahlen nicht. Ein Grund mehr, in einer CI-Pipeline mit GitHub Actions und GHCR explizit für die Zielplattform zu bauen.
Wann Slimmen das Gegenteil bewirkt
Zurück zum 1,03-GB-Image. Das ist mein Strapi-CMS, und die Größe ist eine Entscheidung, kein Versehen. Im Dockerfile steht sie als Kommentar:
# NOTE: keep the full node_modules tree in the runtime image, Strapi's
# admin/runtime resolves packages that a --prod re-install would strip.
FROM node:24-alpine@sha256:156b55f9... AS runner
COPY --from=builder --chown=node:node /app /app
USER node
Der Versuch, in der Runtime-Stage mit einem Production-Only-Reinstall aufzuräumen, entfernte @strapi/typescript-utils, eine Dev-Dependency, die Strapi beim Start trotzdem braucht. Der Container startete nicht mehr und scheiterte an einem tsconfig extends. Laut meiner Notiz von damals wurde das Image dabei nicht einmal kleiner, sondern größer; nachmessen kann ich das nicht mehr, das Image existiert nicht mehr. Plausibel ist es aus derselben Mechanik wie beim gelöschten npm: Der Reinstall kommt als zusätzlicher Layer obendrauf, während der alte Baum im Layer darunter liegen bleibt.
Der Runner kopiert deshalb bewusst den kompletten Baum und läuft als USER node. Ein Detail, das bei diesem Wechsel auffällt: Ein frisch angelegtes Volume gehört root, wenn im Image kein passender Pfad mit anderen Rechten existiert, von dem Docker die Eigentümerschaft übernehmen könnte. Läuft der Prozess dann als uid 1000, scheitert der erste Schreibzugriff mit EACCES. Genau das steht bei mir als Deploy-Landmine in den Notizen, mit dem chown als Gegenmittel. Heute gehört das Uploads-Volume 1000:1000, passend zum id-Ergebnis im Container (uid=1000(node)). Ein separater Schritt vor dem Deploy, kein Automatismus.
Was ich vor dem nächsten Build prüfe
Vier Fragen, die aus den Fehlern oben entstanden sind:
- Läuft überall dieselbe Paketmanager-Hauptversion? Host, CI und Build-Image gegen dasselbe Lockfile. Sonst driftet es wieder auseinander.
- Mit welchem Werkzeug ist das Lockfile entstanden? Nicht mit dem, das auf Ihrem Rechner liegt, sondern mit dem aus dem Build-Image. Sonst enthält die Datei genau die Plattformvarianten Ihres Arbeitsplatzes und keine weitere.
- Ruft die Laufzeit das Werkzeug wirklich auf, das ich mitliefere? Wenn nicht, gehört es raus: für den Scanner, nicht für die Bytes.
- Was passiert, wenn ich in der Runtime-Stage aufräume? Prüfen Sie mit
docker history, ob der Layer tatsächlich Größe spart. Häufig ist er 0 B groß.
Trivy war im März 2026 Ziel einer Supply-Chain-Kompromittierung; pinnen Sie die GitHub Action deshalb per Commit-SHA und das Image per Digest statt über schwimmende Tags, siehe Advisory GHSA-69fq-xp46-6x23.
Wie die fertigen Images danach im Verbund laufen (Abhängigkeiten, Netzwerke und Healthchecks), steht im Beitrag zu Docker-Healthchecks und restart_policy. Wenn Sie eine bestehende .NET-Anwendung containerisieren wollen und dabei jemanden brauchen, der solche Fehler schon einmal selbst gemacht hat: Das ist Teil meiner .NET-Entwicklung.
Offizielle Dokumentation: Docker Multi-Stage Builds, Docker Build Best Practices, .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).