# Docker-Images für Node im Jahr 2026: Multi-Stage, Distroless und das 1,1-GB-Image, das zu 140 MB wurde

Das erste Dockerfile in den meisten Node-Projekten ist elf Zeilen lang, beginnt mit `node:20`, kopiert das Repository, führt `npm install` und `npm run build` aus und wird ausgeliefert. Es funktioniert. Es hat außerdem 1,1 GB, enthält einen C-Compiler, Python, git, jede Dev-Abhängigkeit, das `.git`-Verzeichnis und den Quellcode der App neben ihrem Build-Output. Das haben wir auf einer Next.js-Plattform geerbt, die wir für einen US-Kunden betreiben, und es kostete bei jedem Deploy echte Minuten und echtes Geld in ECR-Speicher und App-Runner-Pull-Zeit.

Hier ist das Dockerfile, das wir jetzt betreiben, mit 140 MB, mit der Begründung für jede Stufe, den zwei Dingen, die beim Wechsel auf Distroless kaputtgingen, und der ehrlichen Anmerkung dazu, was Sie aufgeben.

## Woher die 1,1 GB kamen

| Layer | Größe | Warum er da war |
|---|---|---|
| Basis `node:20` (Debian) | 1.000 MB | Volles Debian mit Build-Toolchain, Python, git |
| `node_modules` inkl. Dev-Abhängigkeiten | 420 MB | TypeScript, ESLint, Jest, Playwright, alles nach Prod ausgeliefert |
| Quellbaum + `.git` | 80 MB | Kopiert, bevor es eine `.dockerignore` gab |
| Build-Output | 60 MB | Der einzige Teil, den die Produktion braucht |

Vier dieser fünf Zeilen sind Verschwendung im laufenden Container. Allein das Basis-Image ist siebenmal so groß wie die App. Und jeder Layer wird bei jedem Scale-out erneut in ECR gepusht und von jeder App-Runner-Instanz erneut gezogen, was jedem Kaltstart zwanzig bis vierzig Sekunden hinzufügte.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Zwei gestapelte Balken vergleichen die Zusammensetzung des Images. Vorher: 1,1 GB aus Debian-Basis 1000 MB, node_modules mit Dev-Abhängigkeiten 420 MB, Quellcode und git 80 MB, Build-Output 60 MB. Nachher: 140 MB aus Distroless-Basis 20 MB, Produktions-node_modules 60 MB, Standalone-Build-Output 60 MB.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#f1f3ff" font-size="14" font-weight="700">Was im Image steckt</text>
<text x="20" y="66" fill="#9aa3c7">vorher · 1,1 GB</text>
<rect x="140" y="52" width="480" height="22" rx="4" fill="#ff6b8a" opacity="0.85"/><text x="380" y="67" text-anchor="middle" fill="#0d1120" font-weight="700">node:20 Debian-Basis · 1.000 MB</text>
<rect x="620" y="52" width="200" height="22" rx="4" fill="#ffd166" opacity="0.85"/><text x="720" y="67" text-anchor="middle" fill="#0d1120" font-size="10">Dev-Deps · 420 MB</text>
<rect x="820" y="52" width="38" height="22" rx="4" fill="#9aa3c7" opacity="0.85"/>
<rect x="858" y="52" width="28" height="22" rx="4" fill="#4fffb0"/>
<text x="20" y="126" fill="#9aa3c7">nachher · 140 MB</text>
<rect x="140" y="112" width="20" height="22" rx="4" fill="#7b8cff"/>
<rect x="160" y="112" width="58" height="22" rx="4" fill="#ffd166" opacity="0.85"/>
<rect x="218" y="112" width="58" height="22" rx="4" fill="#4fffb0"/>
<text x="290" y="127" fill="#f1f3ff">Distroless 20 MB · Prod-Deps 60 MB · Standalone-Output 60 MB</text>
<text x="140" y="158" fill="#9aa3c7" font-size="11">Maßstab: 1 px ≈ 1,5 MB · die App selbst (grün) ist in beiden gleich groß</text>
<g font-size="11"><rect x="140" y="184" width="12" height="12" fill="#ff6b8a"/><text x="158" y="194" fill="#9aa3c7">Basis-OS</text><rect x="240" y="184" width="12" height="12" fill="#ffd166"/><text x="258" y="194" fill="#9aa3c7">node_modules</text><rect x="370" y="184" width="12" height="12" fill="#9aa3c7"/><text x="388" y="194" fill="#9aa3c7">Quellcode + .git</text><rect x="490" y="184" width="12" height="12" fill="#4fffb0"/><text x="508" y="194" fill="#9aa3c7">Build-Output</text><rect x="620" y="184" width="12" height="12" fill="#7b8cff"/><text x="638" y="194" fill="#9aa3c7">Distroless-Runtime</text></g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Sieben Achtel des alten Images liefen nie. Sie wurden trotzdem gepusht, gespeichert und gezogen.</text>
</g>
</svg>
</div>

## Das Dockerfile

```dockerfile
# syntax=docker/dockerfile:1.7

# ---- 1. deps: mit dem Lockfile installieren, den Store cachen ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/*/package.json packages/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

# ---- 2. build: kompilieren, den Standalone-Output erzeugen ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# auf Produktionsabhängigkeiten reduzieren, für die Pakete, die der Standalone-Output braucht
RUN pnpm --filter=web deploy --prod /out

# ---- 3. runtime: nichts außer node und dem Output ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["apps/web/server.js"]
```

Und die `.dockerignore`, die mehr Arbeit leistet als jede einzelne Zeile des Dockerfiles:

```
.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md
```

### Stufe 1, deps

Nur die Manifeste und das Lockfile werden vor `pnpm install` kopiert, also ist der Cache-Schlüssel dieses Layers das Lockfile. Ändern Sie eine Quelldatei, wird der Install-Layer wiederverwendet. Ändern Sie das Lockfile, läuft er erneut, was korrekt ist. Das `--mount=type=cache` behält pnpms inhaltsadressierten Store zwischen Builds auf demselben Builder, sodass selbst eine Lockfile-Änderung meist den lokalen Store statt der Registry trifft. Auf CodeBuild mit dem [Registry-Layer-Cache](/de/blog/monorepo-three-apps-build-only-what-changed) wird diese Stufe in etwa zehn Sekunden wiederhergestellt.

`node:22-bookworm-slim` statt des vollen Images: 200 MB statt eines Gigabytes, hat noch eine Shell und einen Paketmanager für die wenigen nativen Module, die kompilieren müssen. Wir brauchen für diese App weder Python noch gcc; wenn Sie es tun, installieren Sie sie nur in dieser Stufe.

### Stufe 2, build

Kopiert den Quellcode auf die deps-Stufe und führt den Build aus. `output: 'standalone'` in `next.config.js` ist die entscheidende Einstellung: Sie verfolgt, welche Dateien in `node_modules` der Server tatsächlich importiert, und kopiert nur diese nach `.next/standalone`, mit einer minimalen `server.js`. Das macht aus 420 MB `node_modules` 60 MB.

`pnpm deploy --prod` ist doppelter Boden: Für alles, was der Standalone-Tracer übersieht (wir hatten ein dynamisches `require` in einer PDF-Bibliothek), erzeugt es eine saubere, reine Produktionsinstallation der App-Abhängigkeiten.

### Stufe 3, runtime

Distroless. Keine Shell, kein Paketmanager, kein `apt`, kein `curl`, kein `sh`. Nur das Node-Binary, seine Laufzeitbibliotheken, CA-Zertifikate und die Dateien, die wir hineinkopieren. Läuft standardmäßig als Benutzer `nonroot`. Die gesamte Basis hat 20 MB.

Das ist die Stufe, die für Sicherheitsscanner zählt: Die Debian-Basis schleppte rund 180 CVEs in Paketen mit, die die App nie aufrief, die meisten in Werkzeugen wie `perl` und `git`. Distroless trägt eine Handvoll, alle in Node selbst, die Sie durch Anheben des Tags beheben.

## Die zwei Dinge, die kaputtgingen

**1. Der Health Check benutzte `curl`.** Die alte Task-Definition hatte einen Container-Health-Check `curl -f http://localhost:3000/api/health`. In Distroless gibt es kein `curl`. Es gibt auch keine Shell, also scheitert `CMD-SHELL` ebenfalls. Zwei Lösungen, und wir haben die zweite genommen: entweder den HTTP-Health-Check der Plattform statt eines Container-Checks verwenden (App Runner und ALB-Target-Groups können beide nativ HTTP, und [die Route sollte ohnehin dafür entworfen sein](/de/blog/health-checks-that-lie)), oder ein winziges Node-Health-Skript mitliefern und es mit dem Node-Binary ausführen:

```dockerfile
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]
```

**2. Debugging im Container funktionierte nicht mehr.** Keine Shell heißt kein `docker exec -it app sh`. Beim ersten Mal, als jemand einen laufenden Container in Staging inspizieren musste, ging es nicht. Die Antwort, die sich durchgesetzt hat: `docker debug` (Docker Desktop) oder ein Sidecar mit Shell, der den Prozess-Namespace teilt, für den seltenen Fall. In der Praxis wollten die Leute von `exec` eine Konfiguration lesen oder eine Umgebungsvariable prüfen, und beides beantwortet der [Deep-Modus der Health-Route](/de/blog/health-checks-that-lie) und die [strukturierten Logs](/de/blog/your-logs-should-not-know-which-cloud) besser. Wir haben seit fünf Monaten keine Shell in einem Produktionscontainer gebraucht.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dreistufiger Build-Ablauf. Stufe deps auf node:22 slim: Lockfile und Manifeste kopieren, pnpm install mit Cache-Mount, Cache-Schlüssel ist das Lockfile. Stufe build: Quellcode kopieren, next build mit Standalone-Output, pnpm deploy prod. Stufe runtime auf Distroless nodejs22 nonroot: nur Standalone-Output, static und public kopieren; keine Shell, kein Paketmanager, 20-MB-Basis. Pfeile zeigen, dass nur der Build-Output in die Runtime übergeht.">
<defs><marker id="arrD" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="150" y="54" text-anchor="middle" fill="#ffd166" font-weight="700">1 · deps · node:22-slim</text><text x="150" y="80" text-anchor="middle" fill="#f1f3ff">nur Lockfile + Manifeste kopieren</text><text x="150" y="100" text-anchor="middle" fill="#f1f3ff">pnpm install --frozen-lockfile</text><text x="150" y="120" text-anchor="middle" fill="#f1f3ff">--mount=type=cache für den Store</text><text x="150" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">Cache-Schlüssel: das Lockfile</text><text x="150" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">in ~10 s aus dem Registry-Cache</text>
<line x1="282" y1="115" x2="318" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="320" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="54" text-anchor="middle" fill="#7b8cff" font-weight="700">2 · build</text><text x="450" y="80" text-anchor="middle" fill="#f1f3ff">Quellcode kopieren</text><text x="450" y="100" text-anchor="middle" fill="#f1f3ff">next build · output: standalone</text><text x="450" y="120" text-anchor="middle" fill="#f1f3ff">pnpm deploy --prod /out</text><text x="450" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">Tracer behält nur importierte Module</text><text x="450" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">420 MB → 60 MB node_modules</text>
<line x1="582" y1="115" x2="618" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="620" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="750" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">3 · runtime · Distroless</text><text x="750" y="80" text-anchor="middle" fill="#f1f3ff">COPY --from=build standalone</text><text x="750" y="100" text-anchor="middle" fill="#f1f3ff">COPY static + public</text><text x="750" y="120" text-anchor="middle" fill="#f1f3ff">CMD ["apps/web/server.js"]</text><text x="750" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">keine Shell · kein apt · nonroot · 20-MB-Basis</text><text x="750" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">~5 CVEs statt ~180</text>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Nur die letzte Stufe wird ausgeliefert. Die ersten beiden existieren, um gecacht zu werden.</text>
</g>
</svg>
</div>

## Die Zahlen

| | Vorher | Nachher |
|---|---|---|
| Image-Größe | 1,1 GB | 140 MB |
| ECR-Speicher, 30 Tags behalten | 33 GB, 3,30 $/Monat | 4 GB, 0,40 $/Monat |
| Push-Zeit aus CodeBuild | 70 s | 9 s |
| Kalter App-Runner-Pull beim Scale-out | 25–40 s | 4–6 s |
| Vom Scanner gemeldete CVEs | ~180 | 5 |
| Build-Zeit, warmer Cache, eine Seite geändert | 4 Min | 1 Min 40 s |

Die Zahl für den kalten Pull ist die, die Nutzer gespürt haben. Scale-out bei einer Lastspitze bedeutete früher eine halbe Minute, bevor die neue Instanz bedienen konnte; jetzt sind es unter zehn Sekunden, und das ist der Unterschied zwischen einer abgefangenen Spitze und einer Spitze, die eine Seite voller 503 produziert.

## Was Sie aufgeben

- **Keine Shell in der Produktion.** Oben behandelt. Es ist ein Vorteil bis zu dem Tag, an dem Sie eine wollen, und dann wollen Sie einen Sidecar.
- **Native Module müssen vorgebaut sein.** Alles mit einem `node-gyp`-Schritt kompiliert in Stufe 1 gegen Debians glibc, das auch Distroless verwendet, also funktioniert es. Wenn Sie weiter zu einer Alpine- oder musl-Basis gehen, funktioniert es nicht. Aus diesem Grund sind wir bei Debian-basiertem Distroless geblieben.
- **`sharp` und Verwandte brauchen ihre Shared Libraries.** Der Standalone-Tracer kopiert das `.node`-Binary, aber nicht `libvips`. `sharp` bringt seit Version 0.33 sein eigenes mit; ältere Versionen brauchen die Bibliotheken aus der Build-Stufe kopiert. Prüfen Sie nach dem Wechsel die erste Anfrage, die Bilder berührt.
- **Der Benutzer `nonroot` kann keine Ports unter 1024 binden.** Verwenden Sie 3000 und lassen Sie die Plattform mappen. Wenn etwas auf 80 besteht, ist das die Aufgabe der Plattform, nicht des Containers.

## Die Kurzfassung

Schlanke Basis zum Bauen, Distroless zum Laufen, `output: 'standalone'`, damit der Tracer das Ausdünnen übernimmt, eine `.dockerignore`, die Quellcode und `.git` nie in den Build-Kontext lässt, und ein Lockfile-geschlüsselter Install-Layer, den der Registry-Cache wiederherstellt. Von 1,1 GB auf 140 MB, von 40 Sekunden kaltem Pull auf 5, von 180 CVEs auf 5.

Wenn Ihr Node-Image über 500 MB hat, [bringen wir es an einem Tag unter 200](/contact), und der Tag zahlt sich meist im ersten Monat Pull-Zeit aus.
