Docker in der Praxis – Production-Ready Templates für Node.js, .NET, Java & Rust
Von Development bis Production: Optimierte Dockerfiles, Multi-Stage-Builds, Datenbankintegration und Container-Kommunikation für moderne Tech-Stacks
SerieContainer & Orchestrierung
Teil 2 von 3
Im ersten Teil der Serie haben wir die Grundlagen von Docker kennengelernt – was Container sind, wie sie funktionieren und warum sie das “Auf meinem Rechner läuft’s”-Problem lösen. Die Minecraft-Server-Beispiele haben die Konzepte greifbar gemacht.
Jetzt wird es konkret: Production-ready Dockerfiles für echte Technologie-Stacks. Node.js-APIs, .NET Core-Services, Java Spring Boot-Anwendungen, Rust-Microservices – jeder Stack hat seine Eigenheiten, Best Practices und Optimierungsmöglichkeiten.
Wir schauen uns an, wie Container miteinander kommunizieren, wie Datenbanken integriert werden, wie Multi-Stage-Builds Images schlank halten und welche Sicherheits-Aspekte in Production relevant sind.
Multi-Stage Builds – Schlanke Production-Images
Eines der wichtigsten Docker-Features für Production ist der Multi-Stage Build. Die Idee: Build-Dependencies gehören nicht ins finale Image.
Das Problem ohne Multi-Stage
Ein naives Node.js-Dockerfile könnte so aussehen:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
Das funktioniert, aber das finale Image enthält:
- Alle
devDependencies(ESLint, TypeScript, Jest, etc.) - Node.js Build-Tools
- Source-Code in TypeScript (obwohl nur JavaScript gebraucht wird)
- Größe: ~1.2 GB
Die Multi-Stage-Lösung
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Das finale Image enthält:
- Nur Production-Dependencies
- Nur kompiliertes JavaScript
- Minimale Node.js-Runtime (Alpine-based)
- Größe: ~150 MB
Das ist eine Reduktion um 88% – schnellere Deployments, weniger Speicherplatz, kleinere Angriffsfläche.
Node.js – Production-Ready Template
Node.js-Anwendungen sind in Containern weit verbreitet. Hier ein vollständiges Production-Template mit allen Best Practices:
# Syntax-Version festlegen für BuildKit-Features
# syntax=docker/dockerfile:1
# Build Stage
FROM node:20-alpine AS builder
# Metadaten
LABEL stage=builder
# Build-Dependencies installieren
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Dependency-Installation (gecached wenn package.json unverändert)
COPY package*.json ./
RUN npm ci --ignore-scripts
# Source-Code kopieren und bauen
COPY . .
RUN npm run build && \
npm prune --production
# Production Stage
FROM node:20-alpine
# Security: Non-root User
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Nur Production-Artefakte kopieren
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Health-Check definieren
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Non-root ausführen
USER nodejs
EXPOSE 3000
# Graceful Shutdown unterstützen
STOPSIGNAL SIGTERM
CMD ["node", "dist/index.js"]
Die Optimierungen im Detail
Alpine Linux: Basis-Image mit nur 5 MB statt 100+ MB bei Debian-basierten Images.
Layer-Caching: package.json wird vor dem Source-Code kopiert. Wenn sich nur Code ändert, wird die Dependency-Installation aus dem Cache geholt.
npm ci statt npm install: npm ci ist deterministisch, nutzt package-lock.json exakt und ist schneller in CI/CD-Umgebungen.
Production-Pruning: npm prune --production entfernt devDependencies nach dem Build.
Non-Root User: Container laufen als User nodejs (UID 1001), nicht als Root. Security-Best-Practice.
Health-Check: Docker kann automatisch prüfen, ob die Anwendung gesund ist. Wichtig für Orchestrierung.
Graceful Shutdown: STOPSIGNAL SIGTERM ermöglicht der Anwendung, sauber herunterzufahren (Connections schließen, etc.).
.dockerignore – Was nicht ins Image gehört
node_modules
npm-debug.log
dist
.git
.env
.vscode
*.md
.github
coverage
.nyc_output
Ohne .dockerignore werden alle Dateien kopiert – inklusive node_modules, was Build-Zeiten massiv verlängert.
.NET Core – Optimiert für C# und F#
.NET hat exzellente Docker-Integration. Microsoft stellt offizielle Images bereit, die optimal abgestimmt sind:
# Build Stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Projekt-Dateien kopieren und Restore (Layer-Caching)
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
# Source kopieren und bauen
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build "MyApi.csproj" -c Release -o /app/build
# Publish Stage
FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish \
--no-restore \
--runtime linux-x64 \
--self-contained false
# Runtime Stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app
# Non-root User
RUN addgroup -g 1001 -S dotnet && \
adduser -S dotnet -u 1001 -G dotnet
# Artefakte kopieren
COPY --from=publish --chown=dotnet:dotnet /app/publish .
# ASP.NET Core auf Port 8080 konfigurieren (non-privileged)
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
USER dotnet
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "MyApi.dll"]
.NET-spezifische Optimierungen
SDK vs. ASP.NET Runtime: Das SDK-Image (1.2 GB) ist nur für den Build. Das Runtime-Image (Alpine-based, ~100 MB) enthält nur die .NET Runtime.
Layer-Caching für Restore: .csproj-Dateien werden vor dem Source-Code kopiert. NuGet-Packages werden nur neu geholt, wenn sich Dependencies ändern.
Self-Contained vs. Framework-Dependent: Hier --self-contained false – die Runtime ist im Base-Image. Für Deployments ohne .NET-Runtime-Abhängigkeit würde man true nutzen (aber größere Images).
Ready-to-Run (R2R): Für weitere Performance-Optimierung kann PublishReadyToRun aktiviert werden:
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
Das kompiliert IL zu nativem Code, verbessert Startup-Performance um 20-30%.
Trimming: Für noch kleinere Images kann man ungenutzten Code trimmen:
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
Reduziert die App-Größe erheblich, funktioniert aber nur mit Trimming-kompatiblen Libraries.
Java Spring Boot – Enterprise-Ready
Java-Anwendungen haben oft hohe Memory-Anforderungen. Optimierung ist hier besonders wichtig:
# Build Stage
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /app
# Gradle Wrapper und Dependencies (Layer-Caching)
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon
# Source kopieren und bauen
COPY src src
RUN ./gradlew bootJar --no-daemon && \
java -Djarmode=layertools -jar build/libs/*.jar extract
# Runtime Stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Non-root User
RUN addgroup -g 1001 spring && \
adduser -S spring -u 1001 -G spring
# Layered JAR kopieren (Spring Boot Feature)
COPY --from=build --chown=spring:spring /app/dependencies/ ./
COPY --from=build --chown=spring:spring /app/spring-boot-loader/ ./
COPY --from=build --chown=spring:spring /app/snapshot-dependencies/ ./
COPY --from=build --chown=spring:spring /app/application/ ./
USER spring
# JVM-Tuning für Container
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]
Java-spezifische Optimierungen
Layered JARs: Spring Boot 2.3+ unterstützt Layer-Extraktion. Dependencies ändern sich selten, Application-Code oft. Durch Layer-Trennung werden nur geänderte Layer neu gepullt.
JDK vs. JRE: Build benötigt JDK (Compiler), Runtime nur JRE. Das spart ~200 MB.
Container-Aware JVM: -XX:+UseContainerSupport stellt sicher, dass die JVM cgroup-Limits respektiert. Sonst könnte die JVM annehmen, das gesamte Host-Memory steht zur Verfügung.
MaxRAMPercentage: Begrenzt Heap-Größe auf Prozentsatz des Container-Memories. Bei einem Container mit 2 GB RAM nutzt die JVM max. 1.5 GB für Heap.
G1GC: Garbage Collector optimiert für Container-Umgebungen mit begrenztem Memory.
Startup-Optimierung mit CDS (Class Data Sharing):
Für noch schnelleren Start kann man CDS nutzen:
RUN java -Xshare:dump
Das reduziert Startup-Zeit um 20-40%, besonders bei großen Anwendungen.
Rust – Maximale Effizienz
Rust-Anwendungen sind perfekt für Container: Statisch kompiliert, keine Runtime-Dependencies, winzige Binaries.
# Build Stage
FROM rust:1.75-alpine AS builder
# Build-Dependencies
RUN apk add --no-cache musl-dev openssl-dev
WORKDIR /app
# Dependency-Caching: Erst nur Cargo.toml kopieren
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
cargo build --release && \
rm -rf src
# Echter Source-Code
COPY src src
RUN touch src/main.rs && \
cargo build --release
# Runtime Stage: Distroless für minimale Größe
FROM gcr.io/distroless/static-debian12
WORKDIR /app
# Nur Binary kopieren
COPY --from=builder /app/target/release/myapp /app/myapp
# Non-root
USER nonroot:nonroot
EXPOSE 8080
CMD ["/app/myapp"]
Rust-spezifische Optimierungen
musl statt glibc: Alpine nutzt musl-libc. Rust kann damit statisch kompilieren – null Runtime-Dependencies.
Dependency-Caching: Der Trick mit dem Dummy-main.rs cached Dependencies. Änderungen am Source triggern kein Rebuild aller Crates.
Distroless Base-Image: Enthält nur die Binary, sonst nichts. Kein Shell, keine Utilities, kein Package-Manager. Angriffsfläche minimal. Image-Größe: ~5-15 MB (je nach Binary-Größe).
Strip Symbols für kleinere Binaries:
[profile.release]
strip = true
lto = true
codegen-units = 1
Das reduziert Binary-Größe um 30-50% und verbessert Performance durch Link-Time Optimization (LTO).
Cross-Compilation für noch kleinere Images:
Für maximale Optimierung kann man auch komplett ohne OS bauen:
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]
Image-Größe: Binary-Größe + 0 Bytes. Das ist das absolute Minimum.
Datenbank-Integration – PostgreSQL als Beispiel
Containerisierte Anwendungen brauchen Datenbanken. Schauen wir uns eine realistische Setup mit Docker Compose an:
version: '3.8'
services:
# PostgreSQL Datenbank
postgres:
image: postgres:16-alpine
container_name: postgres_db
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
# Persistente Daten
- postgres_data:/var/lib/postgresql/data
# Init-Scripts
- ./init-scripts:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app_network
restart: unless-stopped
# Node.js API
api:
build:
context: ./api
dockerfile: Dockerfile
container_name: nodejs_api
environment:
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@postgres:5432/myapp
NODE_ENV: production
PORT: 3000
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
networks:
- app_network
restart: unless-stopped
# .NET Worker Service
worker:
build:
context: ./worker
dockerfile: Dockerfile
container_name: dotnet_worker
environment:
ConnectionStrings__DefaultConnection: "Host=postgres;Database=myapp;Username=appuser;Password=${DB_PASSWORD}"
depends_on:
postgres:
condition: service_healthy
networks:
- app_network
restart: unless-stopped
volumes:
postgres_data:
networks:
app_network:
driver: bridge
Die Integration im Detail
Netzwerk-Isolation: Alle Services laufen im app_network. Container können sich per Service-Name erreichen: Die API verbindet zu postgres:5432, nicht zu localhost oder einer IP.
Health-Checks: Die Datenbank hat einen Health-Check. depends_on mit condition: service_healthy stellt sicher, dass die API erst startet, wenn PostgreSQL bereit ist.
Secrets Management: Passwörter gehören nicht in docker-compose.yml. Die .env-Datei:
DB_PASSWORD=your_secure_password_here
Für Production würde man Docker Secrets oder Vault nutzen.
Persistente Daten: Das Volume postgres_data überlebt Container-Restarts und -Löschungen. Daten bleiben erhalten.
Init-Scripts: SQL-Dateien in ./init-scripts/ werden beim ersten Start automatisch ausgeführt:
-- init-scripts/01-schema.sql
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
Container-Kommunikation – Services verbinden
Container kommunizieren über verschiedene Mechanismen:
1. Service-Discovery über DNS
Docker Compose registriert jeden Service automatisch im internen DNS. Ein Container namens api erreicht postgres einfach per Hostname:
// Node.js Connection
const pool = new Pool({
host: 'postgres', // Service-Name aus docker-compose.yml
port: 5432,
database: 'myapp',
user: 'appuser',
password: process.env.DB_PASSWORD
});
2. Inter-Service Communication über HTTP
Microservices kommunizieren oft per HTTP:
services:
auth-service:
build: ./auth
networks:
- app_network
user-service:
build: ./users
environment:
AUTH_SERVICE_URL: http://auth-service:8080
networks:
- app_network
Der user-service kann den auth-service per http://auth-service:8080/validate erreichen.
3. Message Queues für asynchrone Kommunikation
Für Event-Driven Architecture nutzt man Message Brokers:
services:
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "5672:5672"
- "15672:15672"
networks:
- app_network
publisher:
build: ./publisher
environment:
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
- rabbitmq
networks:
- app_network
consumer:
build: ./consumer
environment:
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
- rabbitmq
networks:
- app_network
4. Shared Volumes für File-basierte Kommunikation
Für manche Use Cases (z.B. File-Processing) können Container Volumes teilen:
services:
uploader:
build: ./uploader
volumes:
- shared_files:/app/uploads
processor:
build: ./processor
volumes:
- shared_files:/app/input
volumes:
shared_files:
Sicherheits-Best-Practices für Production
1. Immer Non-Root User
Niemals Container als Root laufen lassen:
RUN addgroup -g 1001 appgroup && \
adduser -S appuser -u 1001 -G appgroup
USER appuser
2. Read-Only Filesystem
Wo möglich, Filesystem read-only machen:
services:
api:
build: ./api
read_only: true
tmpfs:
- /tmp
Die Anwendung kann nichts ins Filesystem schreiben (außer /tmp), was viele Exploits verhindert.
3. Ressourcen-Limits setzen
Verhindere, dass ein Container alle Ressourcen frisst:
services:
api:
build: ./api
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
4. Capabilities droppen
Linux Capabilities sind Privilegien. Container brauchen meist nur minimale:
services:
api:
build: ./api
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Nur wenn Port < 1024
5. Security Scanning
Images regelmäßig auf Schwachstellen scannen:
# Mit Trivy
docker run aquasec/trivy image myapp:latest
# Mit Docker Scout
docker scout cves myapp:latest
6. Secrets nicht in Environment Variables
Secrets über Files mounten, nicht via ENV:
services:
api:
build: ./api
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Im Container:
const password = fs.readFileSync('/run/secrets/db_password', 'utf8');
Performance-Optimierungen
1. BuildKit aktivieren
BuildKit ist Dockers neues Build-System – schneller, effizienter:
export DOCKER_BUILDKIT=1
docker build -t myapp .
Oder in docker-compose.yml:
services:
api:
build:
context: ./api
dockerfile: Dockerfile
args:
BUILDKIT_INLINE_CACHE: 1
2. Cache-Mounts für Package-Manager
Wiederverwendung von Package-Caches zwischen Builds:
# Node.js
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# Rust
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release
3. Layer-Reihenfolge optimieren
Seltene Änderungen zuerst, häufige zuletzt:
# 1. Base-Image (ändert sich fast nie)
FROM node:20-alpine
# 2. System-Dependencies (selten)
RUN apk add --no-cache python3
# 3. App-Dependencies (gelegentlich)
COPY package*.json ./
RUN npm ci
# 4. Source-Code (häufig)
COPY . .
4. Multi-Platform Images für ARM
Apple Silicon, AWS Graviton, Raspberry Pi nutzen ARM:
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest .
Ein Image für beide Architekturen.
Debugging containerisierter Anwendungen
Logs ansehen
# Live-Logs
docker compose logs -f api
# Letzte 100 Zeilen
docker compose logs --tail=100 api
In laufenden Container einsteigen
docker exec -it nodejs_api sh
Port-Forwarding für lokales Debugging
services:
api:
build: ./api
ports:
- "9229:9229" # Node.js Debugger-Port
command: node --inspect=0.0.0.0:9229 dist/index.js
Visual Studio Code kann sich dann remote connecten.
Resource-Nutzung überwachen
docker stats
Zeigt CPU, Memory, Network I/O in Echtzeit.
Von Docker Compose zu Kubernetes
Docker Compose funktioniert perfekt für:
- Entwicklungsumgebungen
- Kleine bis mittlere Produktions-Deployments auf einem Host
- CI/CD-Pipelines
- Proof-of-Concepts
Aber was wenn du skalieren musst?
- 50+ Services über 20 Server verteilt
- Automatisches Failover bei Node-Ausfällen
- Load Balancing zwischen Service-Instanzen
- Rolling Updates ohne Downtime
- Auto-Scaling basierend auf Last
Hier erreicht Docker Compose seine Grenzen. Container-Orchestrierung wird notwendig – und das führt uns direkt zu Kubernetes, dem Thema des dritten und letzten Teils dieser Serie.
Erkenntnisse aus der Praxis
Die Dockerfiles und Compose-Setups, die wir hier gesehen haben, sind Production-erprobt. Sie kombinieren:
- Sicherheit: Non-root User, minimale Images, Health-Checks
- Performance: Multi-Stage Builds, Layer-Caching, optimierte Runtimes
- Wartbarkeit: Klare Struktur, Secrets-Management, Logging
Der Unterschied zwischen einem “funktionierenden” und einem “Production-Ready” Docker-Setup liegt in den Details: Health-Checks, Graceful Shutdown, Resource-Limits, Security-Hardening.
Jeder Tech-Stack hat seine Eigenheiten, aber die Prinzipien bleiben gleich: Klein, sicher, effizient, wartbar.
Weiterführende Links: