44 Containerisierung – React-Apps in Docker

Eine React-App lokal zu entwickeln ist einfach: npm install, npm run dev, fertig. Das Problem beginnt beim Deployment. Entwickler-Maschine hat Node 18, Server hat Node 16. Kollege nutzt Windows, Production läuft auf Linux. Dependencies unterscheiden sich. “Works on my machine” ist kein Produktionszustand.

Docker löst dieses Problem durch Containerisierung: Die gesamte App inklusive Abhängigkeiten wird in ein Image gepackt, das überall identisch läuft. Einmal gebaut, läuft der Container auf jedem System mit Docker – egal ob MacBook, Linux-Server oder Kubernetes-Cluster.

Das Besondere bei React: Nach dem Build braucht man kein Node.js mehr. Die App ist statischer Content. Der finale Container kann extrem schlank sein – nur ein Webserver, keine Runtime-Dependencies.

44.1 Das Problem: Node.js-Container sind riesig

Naiver Ansatz: React-App in Node.js-Container packen.

# ❌ Naive Lösung - Viel zu groß!
FROM node:18

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

# Serve mit Node.js Static Server
RUN npm install -g serve
CMD ["serve", "-s", "dist", "-p", "80"]

Probleme:

Problem Details
Image-Größe ~1 GB (Node.js + npm + node_modules)
Security Node.js Runtime + alle Dev-Dependencies
Performance Node.js ist Overkill für Static Files
Overhead Unnötige Tools im Production-Image

Besser: Multi-Stage Build mit Nginx.

44.2 Multi-Stage Build: Build trennen von Runtime

Konzept: Zwei separate Images – eins zum Bauen, eins zum Ausführen.

# Dockerfile

# ===== Stage 1: Build =====
FROM node:18-alpine AS builder

WORKDIR /app

# Dependencies installieren
COPY package*.json ./
RUN npm ci --only=production=false

# Source kopieren und bauen
COPY . .
RUN npm run build

# ===== Stage 2: Production =====
FROM nginx:alpine

# Build-Output vom Builder kopieren
COPY --from=builder /app/dist /usr/share/nginx/html

# Nginx-Konfiguration
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Vergleich:

Metrik Node.js-Container Multi-Stage (Nginx)
Image-Größe ~1000 MB ~25 MB
Startup-Zeit ~5s ~0.5s
Memory ~200 MB ~10 MB
Attack Surface Groß (Node.js) Klein (nur Nginx)

44.3 Nginx-Konfiguration: Client-Side Routing Support

React Router braucht Server-Config, damit alle Routes index.html ausliefern.

# nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip Compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1000;

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Client-Side Routing: Alle Requests → index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache Static Assets
    location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # API Proxy (optional)
    location /api/ {
        proxy_pass http://backend-service:3000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # Health Check Endpoint
    location /health {
        access_log off;
        return 200 "healthy\n";
        add_header Content-Type text/plain;
    }
}

Angepasstes Dockerfile:

# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ===== Runtime =====
FROM nginx:alpine

# Nginx Config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Build Output
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

44.4 .dockerignore: Unnötige Files nicht kopieren

Analog zu .gitignore – verhindert, dass große Files ins Build-Context kopiert werden.

# .dockerignore
node_modules
dist
build
.git
.env.local
.env.*.local
npm-debug.log
.DS_Store
coverage
.vscode
.idea
*.md
!README.md

Warum wichtig?

# Ohne .dockerignore
COPY . .
# Kopiert node_modules (500 MB), .git (200 MB), etc.
# → Docker Build Context: 800 MB

# Mit .dockerignore
COPY . .
# Kopiert nur Source (5 MB)
# → Docker Build Context: 5 MB

Schnellerer Build, kleinerer Context, besseres Caching.

44.5 Build Arguments: Environment-Variablen beim Build

React braucht API-URLs zur Build-Zeit (nicht Runtime), weil Environment-Variablen ins Bundle kompiliert werden.

# Dockerfile mit Build Args
FROM node:18-alpine AS builder

WORKDIR /app

# Build Arguments
ARG VITE_API_URL
ARG VITE_ANALYTICS_ID

# Als Environment-Variablen verfügbar machen
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_ANALYTICS_ID=$VITE_ANALYTICS_ID

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ===== Runtime =====
FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Build mit verschiedenen Environments:

# Development Build
docker build \
  --build-arg VITE_API_URL=http://localhost:3000/api \
  --build-arg VITE_ANALYTICS_ID=UA-DEV \
  -t myapp:dev .

# Production Build
docker build \
  --build-arg VITE_API_URL=https://api.production.com/api \
  --build-arg VITE_ANALYTICS_ID=UA-PROD \
  -t myapp:latest .

44.6 Layer-Caching: Dependencies separat kopieren

Docker cached jeden Layer. Wenn sich nichts ändert, wird Layer wiederverwendet.

Ineffizient:

# ❌ Bei jeder Code-Änderung: npm install neu
COPY . .
RUN npm install
RUN npm run build

Jede Code-Änderung → COPY . . invalidiert Cache → npm install läuft erneut (3 Min).

Effizient:

# ✓ Dependencies cachen
COPY package*.json ./
RUN npm ci
# ← Cache-Layer: Wird nur invallidiert bei package.json-Änderung

COPY . .
RUN npm run build
# ← Läuft nur bei Code-Änderung

Code-Änderung → Nur npm run build (30s), nicht npm ci.

Complete Optimized Dockerfile:

FROM node:18-alpine AS builder

WORKDIR /app

# 1. Dependencies installieren (cached)
COPY package*.json ./
RUN npm ci

# 2. Source kopieren
COPY . .

# 3. Build Arguments
ARG VITE_API_URL
ARG VITE_ANALYTICS_ID
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_ANALYTICS_ID=$VITE_ANALYTICS_ID

# 4. Build
RUN npm run build

# ===== Runtime =====
FROM nginx:alpine

# Non-root User (Security)
RUN addgroup -g 1001 -S nodejs && \
    adduser -S react -u 1001

# Nginx Config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Build Output
COPY --from=builder /app/dist /usr/share/nginx/html

# Permissions
RUN chown -R react:nodejs /usr/share/nginx/html && \
    chown -R react:nodejs /var/cache/nginx && \
    chown -R react:nodejs /var/log/nginx && \
    chown -R react:nodejs /etc/nginx/conf.d

RUN touch /var/run/nginx.pid && \
    chown -R react:nodejs /var/run/nginx.pid

USER react

EXPOSE 8080

CMD ["nginx", "-g", "daemon off;"]

44.7 Docker Compose: Multi-Container Setup

Typisches Setup: React Frontend + API Backend + Database.

# docker-compose.yml
version: '3.8'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        VITE_API_URL: http://localhost:3000/api
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - app-network

  backend:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./backend:/app
    command: npm start
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
    depends_on:
      - db
    networks:
      - app-network

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  db-data:
# Alles starten
docker-compose up

# Mit Rebuild
docker-compose up --build

# Im Hintergrund
docker-compose up -d

# Logs
docker-compose logs -f frontend

# Stoppen
docker-compose down

# Mit Volumes löschen
docker-compose down -v

44.8 Development-Container: Hot-Reload im Docker

Für lokale Entwicklung: Vite Dev-Server im Container mit Volume-Mounts.

# Dockerfile.dev
FROM node:18-alpine

WORKDIR /app

# Dependencies
COPY package*.json ./
RUN npm install

# Source wird via Volume gemounted
EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# docker-compose.dev.yml
version: '3.8'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    volumes:
      - .:/app
      - /app/node_modules  # Anonymous volume für node_modules
    environment:
      - VITE_API_URL=http://localhost:3000/api
    networks:
      - dev-network

networks:
  dev-network:
    driver: bridge
docker-compose -f docker-compose.dev.yml up
# Hot-Reload funktioniert, weil Source gemounted ist

44.9 Production-Readiness: Health Checks

Docker Health Checks prüfen, ob Container healthy ist.

FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html

# Health Check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Health Status prüfen
docker ps
# STATUS: Up 2 minutes (healthy)

# Manuell testen
docker inspect --format='{{.State.Health.Status}}' container-id

44.10 Image-Size Optimierung: Von 1GB zu 25MB

Optimierungen:

Technik Einsparung Implementierung
Alpine statt Debian ~600 MB FROM node:18-alpine
Multi-Stage Build ~300 MB Nur dist/ ins Final Image
.dockerignore ~100 MB Keine node_modules kopieren
npm ci statt install ~50 MB Exact versions, kein cache
Production Dependencies ~200 MB npm ci --only=production

Vergleich:

# ❌ Schlecht: 1.2 GB
FROM node:18
COPY . .
RUN npm install
CMD ["npm", "start"]

# ✓ Gut: 25 MB
FROM node:18-alpine AS builder
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

44.11 Registry: Image speichern und verteilen

44.11.1 Docker Hub

# Login
docker login

# Tag
docker tag myapp:latest username/myapp:latest

# Push
docker push username/myapp:latest

# Pull (auf anderem Server)
docker pull username/myapp:latest
docker run -p 80:80 username/myapp:latest

44.11.2 Private Registry (Harbor, GitLab)

# Login zu Private Registry
docker login registry.company.com

# Tag mit Registry-Prefix
docker tag myapp:latest registry.company.com/team/myapp:latest

# Push
docker push registry.company.com/team/myapp:latest

44.12 CI/CD: Automated Builds

# .github/workflows/docker.yml
name: Docker Build & Push

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            username/myapp:latest
            username/myapp:${{ github.sha }}
          build-args: |
            VITE_API_URL=${{ secrets.API_URL }}
            VITE_ANALYTICS_ID=${{ secrets.ANALYTICS_ID }}
          cache-from: type=registry,ref=username/myapp:buildcache
          cache-to: type=registry,ref=username/myapp:buildcache,mode=max

44.13 Kubernetes Deployment (Ausblick)

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: react-app
  labels:
    app: react-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: react-app
  template:
    metadata:
      labels:
        app: react-app
    spec:
      containers:
      - name: react-app
        image: username/myapp:latest
        ports:
        - containerPort: 80
        resources:
          requests:
            memory: "32Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: react-app-service
spec:
  selector:
    app: react-app
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
  type: LoadBalancer
kubectl apply -f deployment.yaml
kubectl get pods
kubectl get services

44.14 Security Best Practices

1. Non-Root User

# ❌ Root User (Default)
FROM nginx:alpine
# Läuft als root

# ✓ Non-Root User
FROM nginx:alpine
RUN addgroup -g 1001 -S nodejs && adduser -S react -u 1001
USER react

2. Minimal Base Images

# ❌ Große Attack Surface
FROM ubuntu:latest

# ✓ Minimal
FROM nginx:alpine
# Oder noch minimaler: distroless
FROM gcr.io/distroless/static-debian11

3. Image Scanning

# Trivy: Vulnerability Scanner
trivy image username/myapp:latest

# Snyk
snyk container test username/myapp:latest

4. Secrets nicht im Image

# ❌ Falsch: Secret im Image
ENV API_SECRET=abc123

# ✓ Richtig: Secret zur Runtime
# Über Environment Variable oder Docker Secret
docker run -e API_SECRET=abc123 myapp:latest

44.15 Häufige Fehler

Fehler 1: Dependencies in beiden Stages installieren

# ❌ Falsch: npm install in beiden Stages
FROM node:18 AS builder
RUN npm install

FROM nginx:alpine
RUN npm install  # Unnötig! Nginx braucht kein npm

Fehler 2: .env Files im Image

# ❌ Falsch: .env wird ins Image kopiert
COPY . .
# Inkludiert .env → Secret im Image!

# ✓ Richtig: .dockerignore
# .dockerignore
.env
.env.*

Fehler 3: Port-Mismatch

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

# nginx.conf
server {
    listen 8080;  # ← Falsch! EXPOSE sagt 80
}

Fehler 4: Build ohne Cache-Busting

# ❌ Cache wird nie invalided
COPY . .
RUN npm install
# package.json ändert sich → Aber Layer ist cached!

# ✓ Richtig: Dependencies zuerst
COPY package*.json ./
RUN npm ci
COPY . .

Fehler 5: Nginx läuft als daemon

# ❌ Container stoppt sofort
CMD ["nginx"]

# ✓ Foreground Process
CMD ["nginx", "-g", "daemon off;"]

Containerisierung transformiert React-Deployments von “funktioniert auf meinem Rechner” zu “funktioniert überall identisch”. Multi-Stage Builds halten Images schlank, Nginx liefert Static Files performant aus, Docker Compose orchestriert lokale Entwicklung. Das Ergebnis: 25 MB Production-Image statt 1 GB, reproduzierbare Builds, simple CI/CD-Integration. Container sind kein Overhead – sie sind Standard.