Zum Inhalt springen
·11 Min. Lesezeit

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.

Teilen:X / TwitterLinkedIn

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:

dockerfile
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.

dockerfile
# 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:

AnsatzImage-Größe
node:20 + npm install1,1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-Stage + Alpine + nur Produktions-Deps150 MB
Multi-Stage + Alpine + bereinigte Deps95 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:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... Rest des Builds

Diese 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.

dockerfile
# ============================================
# 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:

javascript
// 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:

dockerfile
# 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#

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Secrets Management: Hör auf, Secrets in dein Dockerfile zu packen#

dockerfile
# MACH DAS NIEMALS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Umgebungsvariablen, 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)

dockerfile
# 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 ci

2. Runtime-Secrets über Umgebungsvariablen

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  myapp

3. Docker Secrets (Swarm / Kubernetes)

yaml
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true
javascript
import { readFileSync } from "fs";
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, "utf8").trim();
  } catch {
    return process.env[name.toUpperCase()];
  }
}

Health Checks#

dockerfile
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:

javascript
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#

yaml
# 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#

yaml
# 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: true

Restart-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:

typescript
const nextConfig: NextConfig = {
  output: "standalone",
};

Das Next.js-Produktions-Dockerfile#

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#

AnsatzImage-Größe
node:20 + vollständige node_modules + .next1,4 GB
node:20-alpine + vollständige node_modules + .next600 MB
node:20-alpine + Standalone-Output120 MB

Der Standalone-Output ist transformativ. Ein 1,4-GB-Image wird zu 120 MB.

Image-Scanning und Sicherheit#

Trivy: Deine Images scannen#

bash
trivy image myapp:latest

In CI integrieren, um Builds bei kritischen Schwachstellen scheitern zu lassen:

yaml
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH
    ignore-unfixed: true

Read-Only Filesystem#

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

Alle Capabilities entfernen#

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

Häufige Fallstricke#

Memory Limits und Node.js#

Node.js respektiert Docker-Memory-Limits nicht automatisch. Setze die maximale Heap-Größe:

dockerfile
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#

  • ARG ist nur während des Builds verfügbar.
  • ENV persistiert 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 Cachingpackage.json wird vor Quellcode kopiert
  • Health Check — HEALTHCHECK-Anweisung im Dockerfile
  • Signalbehandlungdumb-init oder --init für ordnungsgemäße SIGTERM-Behandlung
  • Keine Secrets im Image — Kein ENV mit 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-size fü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.

Ähnliche Beiträge