Docker für Node.js: Das produktionsreife Setup, über das niemand spricht
Multi-Stage-Builds, Non-Root-User, Health Checks, Secrets Management und Image-Größenoptimierung. Die Docker-Patterns, die ich für jedes Node.js-Produktions-Deployment verwende.
Die meisten Node.js-Dockerfiles in der Produktion sind schlecht. Nicht „leicht suboptimal" schlecht. Ich meine: als Root laufen, 600MB-Images mit devDependencies ausliefern, keine Health Checks und Secrets, die in Umgebungsvariablen fest codiert sind und die jeder mit docker inspect lesen kann.
Ich weiß das, weil ich diese Dockerfiles geschrieben habe. Jahrelang. Sie funktionierten, also habe ich sie nie hinterfragt. Dann hat eines Tages ein Security-Audit unseren Container gemeldet, der als PID 1 Root mit Schreibzugriff auf das gesamte Dateisystem lief, und mir wurde klar, dass „funktioniert" und „produktionsreif" sehr unterschiedliche Maßstäbe sind.
Das ist das Docker-Setup, das ich jetzt für jedes Node.js-Projekt verwende. Es ist nicht theoretisch. Es betreibt die Services hinter dieser Seite und mehreren anderen, die ich pflege. Jedes Pattern hier existiert, weil ich entweder von der Alternative verbrannt wurde oder jemand anderen dabei beobachtet habe.
Warum dein aktuelles Dockerfile wahrscheinlich falsch ist#
Lass mich raten, wie dein Dockerfile aussieht:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]Das ist das „Hello World" der Dockerfiles. Es funktioniert. Es hat aber mindestens fünf Probleme, die dir in der Produktion wehtun werden.
Als Root laufen#
Standardmäßig läuft das node-Docker-Image als Root. Das bedeutet, dein Anwendungsprozess hat Root-Privilegien innerhalb des Containers. Wenn jemand eine Schwachstelle in deiner App ausnutzt — ein Path-Traversal-Bug, ein SSRF, eine Abhängigkeit mit Backdoor — hat er Root-Zugriff auf das Container-Dateisystem, kann Binaries ändern, Pakete installieren und möglicherweise weiter eskalieren, abhängig von deiner Container-Runtime-Konfiguration.
„Aber Container sind isoliert!" Teilweise. Container-Escapes sind real. CVE-2024-21626, CVE-2019-5736 — das sind reale Container-Ausbrüche. Als Non-Root zu laufen ist eine Defense-in-Depth-Maßnahme. Es kostet nichts und schließt eine ganze Klasse von Angriffen.
devDependencies in der Produktion installieren#
npm install ohne Flags installiert alles. Deine Test-Runner, Linter, Build-Tools, Type-Checker — alles sitzt in deinem Produktions-Image. Das bläht dein Image um hunderte Megabytes auf und vergrößert deine Angriffsfläche.
Alles kopieren#
COPY . . kopiert dein gesamtes Projektverzeichnis ins Image. Das schließt .git (das riesig sein kann), .env-Dateien (die Secrets enthalten), node_modules (die du gleich sowieso neu installierst), Testdateien, Dokumentation, CI-Configs ein — alles.
Keine Health Checks#
Ohne eine HEALTHCHECK-Anweisung hat Docker keine Ahnung, ob deine Anwendung tatsächlich Traffic bedient. Der Prozess könnte laufen aber im Deadlock stecken, keinen Speicher mehr haben oder in einer Endlosschleife feststecken. Docker meldet den Container als „running", weil der Prozess nicht beendet wurde.
Keine Layer-Caching-Strategie#
Alles zu kopieren bevor Abhängigkeiten installiert werden bedeutet, dass das Ändern einer einzelnen Quellcode-Zeile den npm-install-Cache invalidiert. Jeder Build installiert alle Abhängigkeiten von Grund auf neu.
Lass uns das alles beheben.
Multi-Stage-Builds: Der größte einzelne Gewinn#
Multi-Stage-Builds sind die wirkungsvollste Änderung, die du an einem Node.js-Dockerfile machen kannst. Das Konzept ist einfach: verwende eine Stage zum Bauen deiner Anwendung, dann kopiere nur die Artefakte, die du brauchst, in ein sauberes, minimales Final-Image.
# Single Stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Multi-Stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]Die Builder-Stage hat alles: volles Node.js, npm, Build-Tools, Quellcode, devDependencies. Die Runner-Stage hat nur das, was zur Laufzeit benötigt wird. Die Builder-Stage wird komplett verworfen — sie landet nicht im finalen Image.
Echte Größenvergleiche#
Ich habe diese an einem realen Express.js-API-Projekt mit etwa 40 Abhängigkeiten gemessen:
| Ansatz | Image-Größe |
|---|---|
node:20 + npm install | 1,1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-Stage + Alpine + nur Produktions-Deps | 150 MB |
| Multi-Stage + Alpine + bereinigte Deps | 95 MB |
Das ist eine 10-fache Reduktion vom naiven Ansatz. Kleinere Images bedeuten schnellere Pulls, schnellere Deployments und weniger Angriffsfläche.
Warum Alpine?#
Alpine Linux verwendet musl libc statt glibc und enthält keinen Package-Manager-Cache, keine Dokumentation oder die meisten Utilities, die du in einer Standard-Linux-Distribution findest. Das Basis-node:20-alpine-Image ist etwa 50MB groß, verglichen mit 350MB für node:20-slim und über 1GB für das volle node:20.
Der Kompromiss ist, dass einige npm-Pakete mit nativen Bindings (wie bcrypt, sharp, canvas) gegen musl kompiliert werden müssen. In den meisten Fällen funktioniert das einfach — npm lädt die korrekte vorgefertigte Binary herunter. Bei Problemen kannst du Build-Abhängigkeiten in der Builder-Stage installieren:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... Rest des BuildsDiese Build-Tools existieren nur in der Builder-Stage. Sie sind nicht in deinem finalen Image.
Das vollständige Produktions-Dockerfile#
Hier ist das Dockerfile, das ich als Ausgangspunkt für jedes Node.js-Projekt verwende. Jede Zeile ist beabsichtigt.
# ============================================
# Stage 1: Abhängigkeiten installieren
# ============================================
FROM node:20-alpine AS deps
WORKDIR /app
# Abhängigkeiten basierend auf dem Lockfile installieren
COPY package.json package-lock.json ./
# ci ist besser als install: schneller, strikter und reproduzierbar
RUN npm ci --omit=dev
# ============================================
# Stage 2: Anwendung bauen
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# JETZT Quellcode kopieren — Änderungen hier invalidieren den npm-ci-Cache nicht
COPY . .
RUN npm run build
# ============================================
# Stage 3: Produktions-Runner
# ============================================
FROM node:20-alpine AS runner
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# Sicherheit: dumb-init für ordnungsgemäße PID-1-Signalbehandlung
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
# Sicherheit: Non-Root-User verwenden
USER node
WORKDIR /app
# Produktionsabhängigkeiten aus der deps-Stage kopieren
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Gebaute Anwendung aus der Builder-Stage kopieren
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]Lass mich die nicht offensichtlichen Teile erklären.
Warum drei Stages statt zwei?#
Die deps-Stage installiert nur Produktionsabhängigkeiten. Die builder-Stage installiert alles (einschließlich devDependencies) und baut die App. Die runner-Stage kopiert Produktions-Deps von deps und gebauten Code von builder.
Warum nicht Produktions-Deps in der Builder-Stage installieren? Weil die Builder-Stage devDependencies eingemischt hat. Du müsstest npm prune --production nach dem Build ausführen, was langsamer und weniger zuverlässig ist als eine saubere Produktionsabhängigkeits-Installation.
Warum dumb-init?#
Wenn du node server.js in einem Container ausführst, wird Node.js zu PID 1. PID 1 hat spezielles Verhalten in Linux: Es empfängt keine Standard-Signal-Handler. Wenn du SIGTERM an den Container sendest (was docker stop tut), wird Node.js als PID 1 es standardmäßig nicht behandeln. Docker wartet 10 Sekunden und sendet dann SIGKILL, das den Prozess sofort ohne jegliches Aufräumen beendet.
dumb-init agiert als PID 1 und leitet Signale ordnungsgemäß an deine Anwendung weiter:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
});Die .dockerignore-Datei#
Diese ist genauso wichtig wie das Dockerfile selbst:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
Layer Caching#
Die Reihenfolge der Anweisungen ist entscheidend:
# Falsche Reihenfolge
COPY . .
RUN npm ci
# Richtige Reihenfolge
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Jetzt läuft npm ci nur, wenn sich package.json oder package-lock.json ändert. Bei einem Projekt mit 500+ Abhängigkeiten spart das 60-120 Sekunden pro Build.
Cache Mount für npm#
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devSecrets Management: Hör auf, Secrets in dein Dockerfile zu packen#
# MACH DAS NIEMALS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Umgebungsvariablen, die mit ENV in einem Dockerfile gesetzt werden, sind im Image eingebacken. Jeder, der das Image pullt, kann sie mit docker inspect sehen.
1. Build-Time-Secrets (Docker BuildKit)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci2. Runtime-Secrets über Umgebungsvariablen
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
myapp3. Docker Secrets (Swarm / Kubernetes)
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueimport { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
return process.env[name.toUpperCase()];
}
}Health Checks#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"Der Health-Endpoint#
Gib nicht einfach nur 200 zurück — überprüfe tatsächlich, ob deine App gesund ist:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});docker-compose für die Entwicklung#
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
- "9229:9229"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
volumes:
pgdata:
redisdata:Volume Mounts für Hot Reload: Das .:/app-Volume-Mount bildet deinen lokalen Quellcode in den Container ab.
Der node_modules-Trick: Das anonyme Volume - /app/node_modules stellt sicher, dass der Container seine eigenen node_modules verwendet statt die des Hosts. Das ist kritisch, weil native Module, die auf macOS kompiliert wurden, in einem Linux-Container nicht funktionieren.
Produktions-Docker-Compose#
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
networks:
- internal
volumes:
pgdata:
redisdata:
networks:
internal:
driver: bridge
web:
external: trueRestart-Policy: unless-stopped startet den Container automatisch neu, wenn er abstürzt, es sei denn, du hast ihn explizit gestoppt.
Ressourcenlimits: Ohne Limits wird ein Memory Leak in deiner Node.js-App den gesamten verfügbaren RAM des Hosts verbrauchen.
Logging-Konfiguration: Ohne max-size und max-file wachsen Docker-Logs unbegrenzt.
Netzwerkisolation: Das internal-Netzwerk ist nur für Services in diesem Compose-Stack zugänglich.
Next.js: Der Standalone-Output#
Next.js hat eine eingebaute Docker-Optimierung: Standalone-Output-Modus. Aktiviere ihn in next.config.ts:
const nextConfig: NextConfig = {
output: "standalone",
};Das Next.js-Produktions-Dockerfile#
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Größenvergleich für Next.js#
| Ansatz | Image-Größe |
|---|---|
node:20 + vollständige node_modules + .next | 1,4 GB |
node:20-alpine + vollständige node_modules + .next | 600 MB |
node:20-alpine + Standalone-Output | 120 MB |
Der Standalone-Output ist transformativ. Ein 1,4-GB-Image wird zu 120 MB.
Image-Scanning und Sicherheit#
Trivy: Deine Images scannen#
trivy image myapp:latestIn CI integrieren, um Builds bei kritischen Schwachstellen scheitern zu lassen:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueRead-Only Filesystem#
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestAlle Capabilities entfernen#
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueHäufige Fallstricke#
Memory Limits und Node.js#
Node.js respektiert Docker-Memory-Limits nicht automatisch. Setze die maximale Heap-Größe:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]Lass etwa 25% Spielraum zwischen dem Node.js-Heap-Limit und dem Container-Memory-Limit.
Zeitzonen-Probleme#
Alpine verwendet standardmäßig UTC. Aber besser: Schreibe zeitzonen-agnostischen Code. Speichere alles in UTC. Konvertiere erst in der Darstellungsschicht.
Build Arguments vs Umgebungsvariablen#
ARGist nur während des Builds verfügbar.ENVpersistiert im Image und ist zur Laufzeit verfügbar.
Die Checkliste#
Bevor du eine containerisierte Node.js-App in die Produktion schickst, überprüfe:
- Non-Root-User — Container läuft als Non-Root-User
- Multi-Stage-Build — devDependencies und Build-Tools sind nicht im finalen Image
- Alpine-Basis — Minimales Basis-Image verwenden
- .dockerignore —
.git,.env,node_modules, Tests ausgeschlossen - Layer Caching —
package.jsonwird vor Quellcode kopiert - Health Check — HEALTHCHECK-Anweisung im Dockerfile
- Signalbehandlung —
dumb-initoder--initfür ordnungsgemäße SIGTERM-Behandlung - Keine Secrets im Image — Kein
ENVmit sensiblen Werten im Dockerfile - Ressourcenlimits — Memory- und CPU-Limits im Compose/Orchestrator gesetzt
- Log-Rotation — Logging-Driver mit maximaler Größe konfiguriert
- Image-Scanning — Trivy oder Äquivalent in der CI-Pipeline
- Gepinnte Versionen — Basis-Image und Abhängigkeitsversionen gepinnt
- Memory Limits —
--max-old-space-sizefür den Node.js-Heap gesetzt
Die meisten davon sind einmalige Einrichtungen. Mach es einmal, erstelle ein Template, und jedes neue Projekt startet mit einem produktionsreifen Container von Tag eins.
Docker ist nicht kompliziert. Aber die Kluft zwischen einem „funktionierenden" Dockerfile und einem produktionsreifen ist breiter als die meisten denken. Die Patterns in diesem Guide schließen diese Kluft. Verwende sie, passe sie an und hör auf, Root-Container mit 1GB-Images und ohne Health Checks zu deployen. Dein zukünftiges Ich — das, das um 3 Uhr morgens geweckt wird — wird es dir danken.