Docker-Entwicklungsworkflows, die wirklich funktionieren
Hör auf, im Alltag mit Docker zu kämpfen: Multi-Stage-Builds, Bind Mounts, Compose-Overrides und die Muster, die Container nativ wirken lassen.

Docker soll deine Umgebung reproduzierbar machen. In der Praxis landen viele Teams bei Dockerfiles, die in CI laufen, lokal kaputtgehen und ein Ritual aus Tribal Knowledge erfordern, um sie zum Laufen zu bringen. Das Tooling ist mächtig — die Lücke liegt in der Art und Weise, wie es genutzt wird.
Multi-Stage-Builds sind nicht verhandelbar
Ein Single-Stage-Dockerfile, das alles ins Image kopiert, ist die Hauptursache für die meisten Docker-Frustrationen. Es macht Images groß, langsam beim Neubauen und unmöglich sowohl für Entwicklung als auch Produktion zu optimieren.
# ❌ Single-stage — ships dev dependencies, source maps, everything
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
# ✅ Multi-stage — lean production image, proper layer caching
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]Die deps-Stage wird separat gecacht. Wenn sich package.json nicht ändert, wiederverwendet Docker den Layer — dein npm ci läuft nur, wenn sich die Abhängigkeiten tatsächlich ändern.
Docker-Compose-Overrides für lokale Entwicklung
Dieselbe docker-compose.yml in Entwicklung und Produktion zu verwenden, zwingt zu schmerzhaften Kompromissen. Nutze stattdessen eine Basisdatei plus umgebungsspezifische Overrides.
# docker-compose.yml (base — shared config)
services:
api:
build:
context: .
target: deps # stop at deps stage for dev
environment:
DATABASE_URL: postgres://postgres:password@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5# docker-compose.override.yml (dev — auto-merged by Compose)
services:
api:
build:
target: deps
command: npm run dev # hot reload in dev
volumes:
- .:/app # bind mount source
- /app/node_modules # keep container's node_modules
environment:
NODE_ENV: development
ports:
- "3000:3000"
- "9229:9229" # Node debugger# docker-compose.prod.yml (production — explicit override)
services:
api:
build:
target: runner
restart: unless-stopped
environment:
NODE_ENV: productionLokal docker compose up ausführen (merged den Override automatisch), in Produktion docker compose -f docker-compose.yml -f docker-compose.prod.yml up.
Bind Mounts und der node_modules-Trick
Der häufigste Schmerzpunkt in der lokalen Entwicklung: Du mountest deinen Quellcode per Bind Mount in den Container, aber jetzt werden die node_modules des Containers (für Linux gebaut) von den node_modules deines Macs (für macOS/Windows gebaut) überschrieben.
Die Lösung ist ein anonymer Volume, der den Bind Mount bei node_modules maskiert:
volumes:
- .:/app # sync source code
- /app/node_modules # hide host's node_modules, keep container'sDas leere Volume-Ziel /app/node_modules hat für diesen spezifischen Pfad Vorrang vor dem übergeordneten Bind Mount. Dein Quellcode wird synchronisiert; deine plattformspezifischen Binärdateien bleiben intakt.
Layer-Caching in CI
Build-Zeiten summieren sich. Ein schlecht sortiertes Dockerfile lädt bei jedem Code-Change alle Abhängigkeiten neu herunter. Die Regel: sortiere Layer von selten geändert zu oft geändert.
FROM node:22-alpine AS builder
WORKDIR /app
# 1. Copy only the manifest — cached until deps change
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
# 2. Copy generated/config files that rarely change
COPY tsconfig.json ./
COPY prisma ./prisma
RUN npx prisma generate
# 3. Copy source — changes on every commit
COPY src ./src
RUN npm run buildIn GitHub Actions nutzt du cache-from und cache-to mit dem Registry-Driver, um den Layer-Cache über Runs hinweg zu persistieren:
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
target: runner
cache-from: type=registry,ref=ghcr.io/org/app:cache
cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=max
tags: ghcr.io/org/app:${{ github.sha }}Health Checks und Graceful Shutdown
Services, die Health Checks deklarieren, erlauben es Compose (und Kubernetes), zu warten, bis sie bereit sind, bevor abhängige Services starten. Sie zu überspringen verursacht Race Conditions, die sich als inkonsistente Testfehler äußern.
# In your Dockerfile
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1Graceful Shutdown ist in Containern wichtig, weil docker stop SIGTERM sendet. Wenn dein Prozess das nicht behandelt, killt Docker nach dem Timeout hart und verwirft laufende Requests.
// Handle SIGTERM for graceful shutdown
const server = app.listen(3000);
async function shutdown() {
console.log("SIGTERM received, shutting down gracefully");
server.close(async () => {
await db.end(); // close DB connections
process.exit(0);
});
// Force exit if graceful shutdown takes too long
setTimeout(() => process.exit(1), 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);Wichtige Erkenntnisse
- Multi-Stage-Builds sind die Grundlage — trenne die Stufen Dependency-Installation, Build und Runtime für kleinere Images und besseres Caching
- Nutze Compose-Overrides, nicht Umgebungsvariablen, für Dev/Prod-Unterschiede — das Override-Pattern hält beide Umgebungen explizit und reviewbar
- Der anonyme
node_modules-Volume-Trick verhindert Kollisionen plattformspezifischer Binärdateien bei Bind Mounts - Sortiere Dockerfile-Layer von selten geändert zu oft geändert — die Manifeste vor dem Quellcode zu kopieren ist die Cache-Optimierung mit dem höchsten ROI
- Health Checks und Graceful Shutdown sind Produktionsanforderungen —
depends_onmit Health-Bedingungen verhindert Race Conditions;SIGTERM-Handling verhindert verworfene Requests


