Docker Compose: die gesamte Umgebung mit einem einzigen Befehl
- Docker
- DevOps
- Self-hosting
- Infra
Datenbank, Cache, Object Storage, Reverse Proxy und die App selbst, einmal in einer einzigen Datei definiert und gemeinsam gestartet. Compose ist die Art, wie ein Solo-Entwickler einen echten Produktions-Stack ohne ein Runbook betreibt.
"Funktioniert auf meiner Maschine" ist ein Geständnis, kein Statusupdate. Die Lösung besteht darin, aufzuhören, die Umgebung in einem Wiki zu beschreiben, das niemand liest, und stattdessen damit anzufangen, sie in einer Datei zu definieren, die alle ausführen. Docker Compose ist diese Datei. Der gesamte NearYou-Stack, die Next.js-App, der Hintergrundworker, MySQL, Redis, MinIO und Caddy, startet mit docker compose up und nichts weiter. Auf meinem Laptop, auf CI, auf einer frischen Hetzner-Box: derselbe Befehl, dasselbe Ergebnis.
Der Stack, der dazu geführt hat
NearYou ist eine standortbasierte Angebotsplattform. Das Backend benötigt eine relationale Datenbank für Angebote und Unternehmen, einen Cache für Sitzungsdaten und Rate Limiting, Object Storage für hochgeladene Medien, einen Worker-Prozess zum Versenden von Benachrichtigungen und einen Reverse Proxy, der TLS beendet und den Traffic weiterleitet. Das sind fünf bewegliche Teile, bevor eine einzige Zeile Produktcode geschrieben wird. Ohne Docker sind das fünf separate Installationen mit fünf Versionsbeschränkungen, die auseinanderdriften, sobald ein zweiter Entwickler hinzukommt oder ein neuer Server bereitgestellt wird.
Mit Compose werden diese fünf Teile zu zwanzig Zeilen YAML und einem Dockerfile. Die gesamte Umgebung ist ein in git-committierbares Artefakt. Ein neuer Mitwirkender klont das Repository, führt docker compose up aus und ist in fünf Minuten produktiv. Nicht fünf Minuten tippen, fünf Minuten Container-Downloads.

Die echte docker-compose.yml
Dies ist eine gekürzte, aber repräsentative Version der NearYou Compose-Datei. Die echte fügt Umgebungsvariablen aus einer .env-Datei und einige Label-Annotationen für Caddy hinzu, aber die Struktur ist genau diese:
services:
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: nearyou
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
target: runner
environment:
DATABASE_URL: mysql://root:${DB_ROOT_PASSWORD}@mysql:3306/nearyou
REDIS_URL: redis://redis:6379
MINIO_ENDPOINT: http://minio:9000
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
worker:
build:
context: .
dockerfile: Dockerfile
target: worker
environment:
DATABASE_URL: mysql://root:${DB_ROOT_PASSWORD}@mysql:3306/nearyou
REDIS_URL: redis://redis:6379
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
depends_on:
- app
volumes:
mysql_data:
redis_data:
minio_data:
caddy_data:Healthchecks und depends_on: der Teil, den alle überspringen
Die meisten Docker-Tutorials zeigen depends_on als eine Liste von Dienstnamen. Diese Form steuert nur die Startreihenfolge, nicht die Bereitschaft. MySQL startet, Docker markiert die Abhängigkeit als erfüllt, und die App beginnt mit der Verbindung, bevor die Datenbank die Initialisierung abgeschlossen hat. Das Ergebnis ist ein instabiler Start, der etwa einmal von fünf fehlschlägt und einen Stack Trace produziert, der wie ein echter Fehler aussieht.
Die Lösung ist die Form condition: service_healthy, die einen bestandenen Healthcheck erfordert, bevor Docker den abhängigen Dienst freigibt. Schreibe den Healthcheck für jeden zustandsbehafteten Dienst. Für MySQL ist das mysqladmin ping. Für Redis ist es redis-cli ping. Für MinIO ist es der Befehl mc ready local. Mit allen dreien an Ort und Stelle startet die App nur, wenn alle drei Backing-Services wirklich bereit sind, Verbindungen zu akzeptieren. Der Start hört auf, ein Rennen zu sein.
Das Dockerfile: ein Build, zwei Targets
Die Compose-Datei verweist auf zwei Build-Targets: runner für die Next.js-App und worker für den Hintergrundprozess. Beide kommen aus demselben Dockerfile mit Multi-Stage-Builds. Die Stages app und worker teilen dieselbe Basis und dieselbe Node-Installation, haben nur unterschiedliche Entrypoints. Das bedeutet, ein einziger Image-Build deckt beide Dienste ab, und es gibt null Divergenz zwischen der Laufzeitumgebung, die Anfragen bedient, und der, die Jobs verarbeitet.
# Dockerfile (condensed for clarity)
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM base AS builder
COPY . .
RUN npm run build
# The web server target
FROM base AS runner
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
ENV NODE_ENV=production
CMD ["node", "server.js"]
# The worker target
FROM base AS worker
COPY --from=builder /app/dist/worker ./worker
ENV NODE_ENV=production
CMD ["node", "worker/index.js"]In der Produktion erzeugen docker build --target runner und docker build --target worker zwei schlanke Images, die denselben Layer-Cache teilen. Der Worker ist kein anderer Stack, es sind dieselben Abhängigkeiten mit einem anderen Entry Point. Diese Einheitlichkeit ist wichtig beim Debugging: keine Überraschungen nach dem Motto "aber es funktioniert im App-Container".
Named Volumes: Daten, die Container-Neustarts überleben
Container sind dazu gedacht, wegwerfbar zu sein. Die darin enthaltenen Daten sind es nicht. Named Volumes sind der Mechanismus, der sie getrennt hält. Wenn du docker compose down ohne --volumes ausführst, stoppen die Container und werden entfernt, aber mysql_data, redis_data und minio_data bleiben auf dem Host erhalten. Führe docker compose up erneut aus, und alle drei Datenbanken sind genau dort, wo sie waren.
- Named Volumes befinden sich unter
/var/lib/docker/volumes/und überleben den Container-Lebenszyklus. - Bind Mounts (ein Host-Pfad in der Volume-Definition) sind die richtige Wahl für das Caddyfile, denn das ist eine Konfiguration, die man bearbeitet, keine Daten, die man schützt.
docker compose down --volumesist die nukleare Option: es löscht die Volumes. Diese Option niemals in der Produktion ohne ein Backup zur Hand ausführen.- Das MySQL-Volume extern sichern. Named Volumes sind keine Backups. Ein nächtlicher
mysqldump, der außerhalb der Box gespeichert wird, macht das Volume verlässlich.

Einem KI-Agenten ein Langzeitgedächtnis des Stacks geben
Ich verwende Claude Code als primären Coding-Agenten für dieses Projekt. Das Modell kennt Docker im Allgemeinen, weiß aber nicht, welche Image-Tags wir fixieren, welche Dienste Healthchecks mit welchen Befehlen benötigen, wie die Volume-Namen lauten oder warum der MariaDB-Adapter in der Prisma-Konfiguration statt dem MySQL-Adapter ist. Ohne diesen Kontext beginnt jede Agent-Session, die die Infrastruktur berührt, von vorne und produziert plausibel klingende, aber falsche Ausgaben.
Die Lösung ist eine persistente Markdown-Gedächtnisdatei, die ins Repository committiert wird. AGENTS.md (oder ein dediziertes DOCKER.md) zeichnet die Konventionen, Befehle und Fallstricke in Klartext auf. Der Agent liest sie am Anfang jeder Session, die den Stack betrifft. Da die Datei in git lebt, bleibt sie akkurat, wenn sich der Stack weiterentwickelt: die Compose-Datei aktualisieren, die Doku aktualisieren, beides committen.
Hier ist das tatsächliche Format, das ich verwende. Es ist kein Tutorial, sondern eine kompakte Referenz, die der Agent in zehn Sekunden aufnehmen kann:
# Docker Stack Reference (NearYou)
## Services
| Service | Image | Role |
|---------|------------------|-------------------------------|
| mysql | mysql:8.4 | Primary database |
| redis | redis:7-alpine | Sessions, rate limits, queues |
| minio | minio/minio | Object storage (S3-compat) |
| app | build ./ | Next.js app (target: runner) |
| worker | build ./ | BullMQ worker (target: worker)|
| caddy | caddy:2-alpine | Reverse proxy, TLS |
## Key commands
- Start: `docker compose up -d`
- Rebuild app after code change: `docker compose build app worker && docker compose up -d app worker`
- Tail logs: `docker compose logs -f app worker`
- MySQL shell: `docker compose exec mysql mysql -u root -p nearyou`
- MinIO CLI: `docker compose exec minio mc ls local/uploads`
## Healthchecks
Every stateful service has a healthcheck. The app and worker use
`depends_on: condition: service_healthy`. Never remove them.
MySQL needs `start_period: 20s` because it is slow on first init.
## Volumes
Named volumes: mysql_data, redis_data, minio_data, caddy_data.
`docker compose down` is safe. `docker compose down --volumes` deletes data.
## Production rebuild (fresh Hetzner box)
1. Install Docker and Docker Compose plugin.
2. Clone repo, copy .env from secrets manager.
3. `docker compose pull && docker compose build`
4. `docker compose up -d`
5. Restore latest MySQL dump into mysql container.
## Gotchas
- Prisma uses the MariaDB adapter against MySQL 8.4. Do not swap to the MySQL
adapter without testing migrations first.
- MinIO healthcheck uses `mc ready local`, not curl. The mc binary is bundled
in the minio image.
- Caddy auto-provisions TLS. Ports 80 and 443 must be open. Caddyfile lives at
./Caddyfile in the repo root.Wenn der Agent eine Session öffnet, um einen neuen Dienst hinzuzufügen, einen Startfehler zu debuggen oder ein Migrationsskript zu schreiben, liest er zuerst diese Datei. Er kennt den für MySQL fixierten Image-Tag, bevor er einen Healthcheck-Befehl schreibt. Er weiß, die Volume-Flags nicht anzufassen, bevor er einen Teardown vorschlägt. Die Qualität der Ausgabe korreliert direkt mit der Qualität dieser Datei. Sie aktuell zu halten kostet fünf Minuten Arbeit pro Änderung. Die Alternative ist, den gesamten Stack in jeder Session neu zu erklären.
Einen neuen Server in wenigen Minuten aufbauen
Der endgültige Test eines mit Compose definierten Stacks ist: Wie lange dauert es, von einer leeren Hetzner-Box zu einer laufenden Produktionsumgebung zu gelangen? Für NearYou beträgt die Antwort etwa fünfzehn Minuten, und der Großteil davon ist das Warten darauf, dass Docker Images herunterlädt.
- 01Box bereitstellen, Docker Engine und das Compose-Plugin installieren.
- 02Repository klonen und die
.env-Datei aus dem Secrets-Manager einfügen. - 03
docker compose pullzum Vorab-Laden der Images, danndocker compose buildfür die benutzerdefinierten Targets. - 04
docker compose up -d, um alles im Detached-Modus zu starten. - 05Den neuesten MySQL-Dump wiederherstellen:
docker compose exec -T mysql mysql -u root -p nearyou < backup.sql. - 06Den DNS-A-Record auf die neue IP zeigen lassen. Caddy stellt TLS automatisch bei der ersten Anfrage bereit.
Es gibt kein Runbook. Es gibt keine Checkliste, von der man einen Punkt vergessen könnte. Die Umgebung ist im Repository definiert, die Befehle stehen im DOCKER.md, und der Agent kann das Wiederherstellungsverfahren ausführen, ohne dass ich es Schritt für Schritt erkläre. Das ist der kumulative Nutzen: Die Compose-Datei macht den Stack reproduzierbar, und das Markdown-Gedächtnis macht den Stack agentenbedienbar. Zusammen bedeuten sie, dass ein ausgefallener Server eine fünfzehnminütige Unannehmlichkeit ist, keine Krise.
Die Umgebung ist Code. Wenn sie nicht im Repository ist, existiert sie nicht.