schlanke Production-Images ohne Compiler-Ballast
Wer ein TypeScript-Projekt ohne Multi-Stage-Build in ein Docker-Image packt, verschifft den kompletten TypeScript-Compiler, alle devDependencies und den Quellcode in die Produktion. Ein sauberer Multi-Stage-Build trennt Kompilierung, Dependency-Bereinigung und Runtime in drei Stufen und reduziert Image-Größe, Angriffsfläche und Deployment-Zeit spürbar.
Inhaltsverzeichnis
- 1. Warum ein TypeScript-Image anders gebaut werden muss
- 2. Die Drei-Stufen-Architektur: Build, Prune, Runtime
- 3. Die Build-Stage: TypeScript kompilieren mit vollen Dependencies
- 4. Dependency-Trennung: npm ci und production-only node_modules
- 5. Die Runtime-Stage: Nur kompiliertes JavaScript ausliefern
- 6. Layer-Caching optimieren: COPY-Reihenfolge und BuildKit
- 7. Monorepo-Besonderheiten: Workspaces im Multi-Stage-Build
- 8. Sicherheit: Non-Root-User, Base-Images, Secrets
- 9. Docker-Strategien für TypeScript im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein TypeScript-Image anders gebaut werden muss
Ein naives Dockerfile für ein TypeScript-Projekt kopiert den gesamten Quellcode in ein Image, führt npm install mit allen devDependencies aus, kompiliert mit tsc und startet direkt aus demselben Image heraus. Das funktioniert, produziert aber ein Image, das den kompletten TypeScript-Compiler, ESLint, Testframeworks und Type-Definitionen enthält, obwohl die Runtime nur kompiliertes JavaScript und die produktiven Abhängigkeiten braucht. Bei einem mittelgroßen Backend-Projekt macht das leicht den Unterschied zwischen einem 180 MB großen und einem 950 MB großen Image aus.
Die Größe ist dabei nicht nur eine Frage der Festplatte. Größere Images bedeuten längere Push- und Pull-Zeiten in der CI-Pipeline, langsamere Rollouts bei Kubernetes-Deployments mit vielen Replikas und eine größere Angriffsfläche, weil Compiler, Build-Tools und Quellcode-Metadaten im laufenden Container verfügbar bleiben. Multi-Stage Builds lösen dieses Problem, indem sie den Docker-Build in mehrere benannte Phasen aufteilen, von denen nur die letzte tatsächlich das Runtime-Image bildet. Alles, was zur Kompilierung gebraucht wurde, bleibt in einer Zwischenstufe zurück und landet nie im finalen Image.
2. Die Drei-Stufen-Architektur: Build, Prune, Runtime
Für TypeScript-Projekte hat sich ein Muster mit drei Stufen bewährt, das über die einfache Zwei-Stufen-Variante (Build und Runtime) hinausgeht. Die erste Stufe installiert alle Abhängigkeiten inklusive devDependencies und kompiliert den TypeScript-Code zu JavaScript. Die zweite Stufe, oft als Prune-Stage bezeichnet, installiert die Abhängigkeiten ein zweites Mal, diesmal ausschließlich mit den produktiven Paketen aus dependencies. Die dritte Stufe kopiert nur das kompilierte JavaScript aus Stufe eins und die bereinigten node_modules aus Stufe zwei in ein minimales Basis-Image.
Der Grund für die separate Prune-Stage statt eines einfachen npm prune --production im selben Layer liegt in der Zuverlässigkeit: npm prune nach der Installation entfernt zwar devDependencies, hinterlässt aber oft verwaiste Unterabhängigkeiten und verändert nicht die Layer-Historie des Images, wodurch alte, größere Layer im Image-Verlauf erhalten bleiben. Eine frische npm ci --omit=dev-Installation in einer eigenen Stage erzeugt dagegen ein garantiert minimales node_modules-Verzeichnis ohne Altlasten. Diese drei Stufen zusammen sind das Kernmuster jedes produktionsreifen TypeScript-Docker-Setups.
# syntax=docker/dockerfile:1.7
# Dockerfile — three-stage build for a TypeScript backend service
# ---------- Stage 1: build ----------
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build # tsc -p tsconfig.json -> dist/
# ---------- Stage 2: prune (production-only dependencies) ----------
FROM node:20-slim AS prune
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---------- Stage 3: runtime (final image) ----------
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=prune /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
3. Die Build-Stage: TypeScript kompilieren mit vollen Dependencies
Die Build-Stage ist die einzige Stufe, in der der TypeScript-Compiler überhaupt installiert sein muss. Hier werden alle Abhängigkeiten inklusive typescript, @types/*-Paketen und gegebenenfalls Bundlern oder Linter installiert, weil tsc selbst als devDependency geführt wird. Wichtig ist, dass diese Stufe ausschließlich für die Kompilierung existiert und niemals direkt als Runtime-Image verwendet wird, auch wenn das technisch möglich wäre.
Ein Detail, das häufig übersehen wird: Die tsconfig.json sollte "declaration" nur aktivieren, wenn tatsächlich .d.ts-Dateien für Konsumenten des Pakets gebraucht werden, da jede zusätzliche Ausgabedatei die Build-Zeit und die Menge an Dateien erhöht, die später kopiert werden müssen. Ebenso lohnt sich "sourceMap": false im produktiven Build-Profil, wenn Source Maps nicht explizit für Error-Tracking im Live-Betrieb benötigt werden, denn sie verdoppeln effektiv die Anzahl der Ausgabedateien in dist. Die Build-Stage sollte zudem mit einem eigenen tsconfig.build.json arbeiten, das Test-Dateien über exclude explizit ausschließt, damit keine Testcode-Artefakte im Ausgabeverzeichnis landen.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"outDir": "dist"
},
"exclude": [
"**/*.test.ts",
"**/*.spec.ts",
"src/**/__mocks__/**",
"dist"
]
}
4. Dependency-Trennung: npm ci und production-only node_modules
Der Kern eines schlanken TypeScript-Images liegt in der strikten Trennung zwischen Build-Time- und Runtime-Dependencies. In der package.json gehören typescript, @types/node, Test-Frameworks wie jest oder vitest und Linter ausnahmslos in devDependencies, niemals in dependencies. Nur so kann die Prune-Stage mit npm ci --omit=dev ein node_modules-Verzeichnis erzeugen, das ausschließlich zur Laufzeit benötigte Pakete wie express, pg oder ioredis enthält.
npm ci statt npm install ist dabei keine Stilfrage, sondern eine Notwendigkeit für reproduzierbare Docker-Builds. npm ci liest ausschließlich die package-lock.json, installiert exakt die dort fixierten Versionen und löscht ein eventuell vorhandenes node_modules-Verzeichnis vorher komplett. Damit ist ausgeschlossen, dass ein Docker-Build je nach Cache-Zustand unterschiedliche Paketversionen zieht. Für Monorepos mit mehreren Workspaces gilt dieselbe Logik pro Paket, wobei npm ci --omit=dev --workspace=@app/api gezielt nur die Runtime-Dependencies eines einzelnen Workspace installiert.
{
"name": "typescript-docker-service",
"version": "2.4.0",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"test": "vitest run"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.12.0",
"ioredis": "^5.4.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"@types/node": "^20.14.10",
"@types/express": "^4.17.21",
"vitest": "^2.0.5"
}
}
5. Die Runtime-Stage: Nur kompiliertes JavaScript ausliefern
Die Runtime-Stage ist bewusst minimal gehalten und enthält weder tsc noch den TypeScript-Quellcode. Sie kopiert ausschließlich das Ergebnis der Kompilierung, also den dist-Ordner aus der Build-Stage, sowie das bereinigte node_modules aus der Prune-Stage. Weil der Container zur Laufzeit nur mit fertigem JavaScript arbeitet, ist typescript selbst zur Laufzeit gar nicht mehr vorhanden, was sowohl die Image-Größe reduziert als auch verhindert, dass jemand versehentlich ts-node in Produktion startet.
Ein zweiter Vorteil dieser Trennung: Die Runtime-Stage kann ein anderes, noch schlankeres Basis-Image verwenden als die Build-Stage. Während die Build-Stage node:20-slim oder sogar das volle node:20-Image braucht, um native Module während npm ci zu kompilieren, reicht für die Runtime oft node:20-alpine oder ein Distroless-Image von Google aus, sofern keine native Addons zur Laufzeit dynamisch nachgeladen werden. Diese Entkopplung der Basis-Images pro Stufe ist einer der größten Hebel, um die Gesamtgröße eines TypeScript-Production-Images unter 150 MB zu drücken.
6. Layer-Caching optimieren: COPY-Reihenfolge und BuildKit
Die Reihenfolge der COPY-Anweisungen entscheidet direkt darüber, wie oft Docker den teuren npm ci-Schritt neu ausführen muss. Wird zuerst nur package.json und package-lock.json kopiert und erst danach der restliche Quellcode, bleibt der Installations-Layer im Docker-Cache erhalten, solange sich die Abhängigkeiten nicht geändert haben, auch wenn sich der TypeScript-Quellcode in jedem Commit ändert. Wird dagegen der gesamte Kontext in einem einzigen COPY . . kopiert, invalidiert jede noch so kleine Codeänderung den kompletten Installations-Cache, und jeder Build installiert alle Pakete neu.
Docker BuildKit bringt darüber hinaus Cache Mounts mit, die den npm-Cache über mehrere Builds hinweg persistieren, ohne ihn im Image-Layer selbst zu speichern. Mit RUN --mount=type=cache,target=/root/.npm npm ci greift npm auf einen dauerhaften Cache-Ordner außerhalb des Image-Layers zu, was besonders in CI-Umgebungen mit BuildKit-Cache-Export (--cache-to/--cache-from) den Build selbst nach einem kompletten Cache-Miss der Layer erheblich beschleunigt. Für TypeScript-Monorepos mit Turborepo lässt sich zusätzlich der Turborepo-eigene Remote-Cache in die Build-Stage einbinden, wodurch bereits kompilierte Pakete gar nicht erst neu gebaut werden müssen.
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS build
WORKDIR /app
# Only lockfiles first — this layer is cached until dependencies change
COPY package.json package-lock.json ./
# BuildKit cache mount: persists npm cache across builds without bloating layers
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Source code changes do NOT invalidate the npm ci layer above
COPY tsconfig.build.json ./
COPY src ./src
RUN npm run build
# Build with: DOCKER_BUILDKIT=1 docker build --cache-to type=registry,ref=myrepo/cache .
7. Monorepo-Besonderheiten: Workspaces im Multi-Stage-Build
In einem TypeScript-Monorepo mit npm-, pnpm- oder Yarn-Workspaces reicht das einfache Drei-Stufen-Muster nicht mehr aus, weil ein einzelner Service typischerweise von mehreren internen Paketen abhängt, die selbst kompiliert werden müssen. Der bewährte Ansatz ist, in der Build-Stage zunächst nur die relevanten package.json-Dateien aller betroffenen Workspaces zu kopieren, dann die Installation für den gesamten Workspace-Baum auszuführen und erst danach den vollständigen Quellcode zu kopieren. Turborepo und Nx bieten dafür eigene prune-Befehle, die aus einem großen Monorepo ein minimales Sub-Repository für genau einen Service extrahieren.
turbo prune --scope=@app/api --docker erzeugt beispielsweise ein out/-Verzeichnis mit zwei Unterordnern: json/ mit nur den benötigten package.json-Dateien für einen cache-freundlichen Installations-Layer, und full/ mit dem tatsächlichen Quellcode aller Abhängigkeiten des Services. Diese Trennung erlaubt es, im Dockerfile zuerst nur out/json zu kopieren und zu installieren, bevor out/full für den eigentlichen Build hinzukommt, was den Layer-Cache auch in großen Monorepos mit Dutzenden Paketen wirksam hält.
8. Sicherheit: Non-Root-User, Base-Images, Secrets
Ein oft vernachlässigter Aspekt bei TypeScript-Docker-Images ist, dass der finale Container standardmäßig als root-Nutzer läuft, sofern nicht explizit ein anderer Benutzer gesetzt wird. Die offiziellen Node.js-Images bringen dafür bereits einen vorbereiteten node-Systembenutzer mit, der in der Runtime-Stage mit USER node aktiviert werden sollte, bevor der Container startet. Läuft ein kompromittierter Prozess innerhalb des Containers nicht als Root, ist der potenzielle Schaden bei einem Container-Escape oder einer Schwachstelle in einer Abhängigkeit erheblich begrenzt.
Ein zweiter Sicherheitsaspekt betrifft Secrets während des Builds: Datenbank-Zugangsdaten oder private npm-Registry-Tokens dürfen niemals über ein einfaches ARG oder ENV im Dockerfile übergeben werden, weil diese Werte in der Image-Historie sichtbar bleiben und mit docker history ausgelesen werden können. BuildKit-Secrets über RUN --mount=type=secret,id=npmrc injizieren sensible Dateien nur temporär während eines einzelnen RUN-Befehls, ohne sie in irgendeinem Layer zu persistieren. Für TypeScript-Projekte mit privaten Scoped-Packages ist das der einzige sichere Weg, eine .npmrc mit Auth-Token während npm ci verfügbar zu machen.
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS build
WORKDIR /app
# Private registry token injected only for this single RUN step,
# never persisted in any image layer or history
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY src ./src
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
# Drop root privileges before the container ever runs application code
USER node
CMD ["node", "dist/main.js"]
# Build with: docker build --secret id=npmrc,src=$HOME/.npmrc .
9. Docker-Strategien für TypeScript im Vergleich
Nicht jede Docker-Strategie für TypeScript passt zu jedem Projekt. Die folgende Übersicht zeigt die gängigen Ansätze mit ihren Kompromissen bei Image-Größe, Build-Geschwindigkeit und Betriebssicherheit.
| Strategie | Image-Größe | Build-Zeit (Cache-Hit) | Empfehlung |
|---|---|---|---|
| Single-Stage, ts-node zur Laufzeit | 800–1200 MB | Schnell | Nur für lokale Prototypen |
| Zwei-Stufen (Build + Runtime, npm prune) | 300–450 MB | Mittel | Akzeptabel für kleine Teams |
| Drei-Stufen (Build, Prune, Runtime) | 120–180 MB | Schnell mit Cache | Empfohlener Standard |
| Drei-Stufen + Alpine/Distroless Runtime | 60–110 MB | Schnell mit Cache | Beste Wahl ohne native Addons |
| Monorepo mit turbo prune --docker | 100–160 MB pro Service | Sehr schnell im Cache | Pflicht ab mehreren Services |
Der Sprung von der Zwei- zur Drei-Stufen-Strategie ist meist der größte einzelne Hebel, weil er verhindert, dass irgendein devDependency-Rest über npm prune im finalen Layer verbleibt. Der zweite große Hebel ist der Wechsel des Basis-Images für die Runtime-Stage, sobald keine nativen Node-Addons kompiliert werden müssen. Für Monorepos ist turbo prune --docker praktisch alternativlos, sobald mehr als ein Service aus demselben Repository gebaut wird.
Mironsoft
TypeScript-Deployments, Docker-Pipelines und CI/CD-Infrastruktur
TypeScript-Images, die in Produktion schlank und sicher laufen?
Wir analysieren bestehende Dockerfiles, bauen Multi-Stage-Pipelines für TypeScript-Projekte und optimieren Layer-Caching, Image-Größe und Sicherheitseinstellungen für eure CI/CD-Infrastruktur.
Dockerfile-Audit
Analyse bestehender TypeScript-Images auf Größe, Layer-Struktur und Sicherheitsrisiken
Multi-Stage-Migration
Umbau auf Build-, Prune- und Runtime-Stage mit BuildKit-Caching
CI-Integration
Registry-Caching, Non-Root-Container und Secret-Handling in der Pipeline verankern
10. Zusammenfassung
Ein produktionsreifes TypeScript-Docker-Image entsteht durch die konsequente Trennung von Build-Time und Runtime. Die Build-Stage kompiliert mit vollständigen devDependencies, die Prune-Stage installiert die produktiven Abhängigkeiten frisch mit npm ci --omit=dev, und die Runtime-Stage kopiert nur das Ergebnis beider Stufen in ein minimales Basis-Image. Diese Drei-Stufen-Architektur senkt die Image-Größe typischerweise von über 800 MB auf 120 bis 180 MB und eliminiert den TypeScript-Compiler vollständig aus der Produktionsumgebung.
Layer-Caching über eine bewusste COPY-Reihenfolge und BuildKit-Cache-Mounts beschleunigt wiederholte Builds erheblich, während Non-Root-User und BuildKit-Secrets die Angriffsfläche des finalen Containers reduzieren. In Monorepos übernehmen Tools wie Turborepo mit prune --docker die Extraktion der relevanten Workspace-Dateien, damit auch bei vielen Services jeweils nur ein minimales, service-spezifisches Image entsteht.
TypeScript in Docker Multi-Stage Builds — Das Wichtigste auf einen Blick
Drei Stufen
Build-Stage kompiliert, Prune-Stage installiert production-only, Runtime-Stage kopiert nur das Ergebnis beider Stufen.
npm ci statt npm install
Garantiert reproduzierbare, gesperrte Versionen und ein sauberes node_modules ohne Cache-Altlasten.
Caching & Sicherheit
COPY-Reihenfolge, BuildKit Cache Mounts, Non-Root-User und BuildKit-Secrets statt ARG/ENV.
Monorepos
turbo prune --docker extrahiert ein minimales Sub-Repository pro Service für kurze, gecachte Builds.