Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· DevOps  · 7 minuten Lesezeit

Docker Volumes in Produktion mit Named Volumes, Bind Mounts und der Hetzner-Volume-Trick

Ich habe gelernt, dass ein Docker Named Volume in Produktion nicht automatisch auf einem Hetzner Volume landet. Erst wenn ich den Pfad bewusst steuere, weiß ich wirklich, wo Qdrant, Redis und Zertifikate geschrieben werden.

Ich habe gelernt, dass ein Docker Named Volume in Produktion nicht automatisch auf einem Hetzner Volume landet. Erst wenn ich den Pfad bewusst steuere, weiß ich wirklich, wo Qdrant, Redis und Zertifikate geschrieben werden.

Inhalt

Das Problem mit Named Volumes in Produktion

Named Volumes sind lokal großartig. Ich schreibe einfach qdrant_data:/qdrant/storage und Docker kümmert sich um den Rest. Für lokale Entwicklung ist das genau richtig, weil ich keinen Pfad pflegen muss und trotzdem persistente Daten bekomme.

In Produktion hat diese Bequemlichkeit aber eine blinde Stelle. Docker verwaltet das Volume intern und legt die Daten typischerweise unter /var/lib/docker/volumes/.../_data auf dem Root-Disk des Servers ab. Wenn ich auf einer Hetzner VM zusätzlich ein Hetzner Volume an /mnt/data mounte, ändert das für Docker Named Volumes erst einmal gar nichts. Sie schreiben weiter auf das lokale Root-Disk der VM.

Genau das ist der Denkfehler, den ich vermeiden will. Ein zusätzlich angehängtes Hetzner Volume schützt meine Daten nicht automatisch. Es schützt sie nur dann, wenn meine Container auch wirklich dorthin schreiben.

So sieht die bequeme lokale Konfiguration heute aus:

# docker-compose.yml
services:
  qdrant:
    volumes:
      - qdrant_data:/qdrant/storage

Lokal ist das gut. In Produktion ist es mir zu intransparent.

Bind Mounts mit eigenem Pfad

Ein Bind Mount ist die ehrliche Variante. Ich schreibe den Zielpfad selbst hin und sehe damit sofort, wo die Daten landen.

services:
  qdrant:
    volumes:
      - ./data/qdrant:/qdrant/storage

Oder in Produktion explizit so:

services:
  qdrant:
    volumes:
      - /mnt/data/qdrant:/qdrant/storage

Der Vorteil ist offensichtlich: keine Magie, keine Docker-interne Ablage, keine falsche Annahme über das Root-Disk. Der Nachteil ist ebenfalls klar. Wenn ich den Produktionspfad direkt in docker-compose.yml fest eintrage, passt dieselbe Datei lokal nicht mehr sauber. Dann beginne ich sehr schnell mit mehreren fast gleichen Compose-Dateien oder mit manuellen Anpassungen pro Umgebung.

Die driver_opts Alternative

Docker kennt dafür noch einen Mittelweg. Ich kann ein Named Volume definieren und ihm intern sagen, dass es eigentlich nur ein Bind Mount auf einen festen Pfad ist.

volumes:
  qdrant_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/qdrant

Technisch funktioniert das gut. Ich bekomme den Namen eines Volumes und trotzdem einen expliziten Pfad. Für mich bleibt es aber schwerer lesbar als nötig. Wer diese Syntax nicht regelmäßig benutzt, muss erst einmal erinnern, was type: none, o: bind und device hier genau bedeuten.

Der env var Trick für das Beste aus beiden Welten

Die sauberste Variante ist für mich deshalb ein einfacher Compose-Ausdruck mit Default-Wert:

services:
  qdrant:
    volumes:
      - ${QDRANT_DATA_DIR:-qdrant_data}:/qdrant/storage
    restart: unless-stopped

  redis:
    command: redis-server --appendonly yes --appendfsync everysec
    volumes:
      - ${REDIS_DATA_DIR:-redis_data}:/data
    restart: unless-stopped

  traefik:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ${LETSENCRYPT_DATA_DIR:-letsencrypt_data}:/letsencrypt
    restart: unless-stopped

  loki:
    volumes:
      - ${LOKI_DATA_DIR:-loki_data}:/loki
    restart: unless-stopped

  grafana:
    volumes:
      - ${GRAFANA_DATA_DIR:-grafana_data}:/var/lib/grafana
    restart: unless-stopped

  ollama:
    volumes:
      - ${OLLAMA_DATA_DIR:-ollama_data}:/root/.ollama
    restart: unless-stopped

volumes:
  qdrant_data:
  redis_data:
  letsencrypt_data:
  loki_data:
  grafana_data:
  ollama_data:

Der Trick liegt in :-. Wenn QDRANT_DATA_DIR nicht gesetzt ist, nutzt Docker das Named Volume qdrant_data. Das ist ideal für lokal. Wenn ich in Produktion QDRANT_DATA_DIR=/mnt/data/qdrant setze, wird daraus automatisch ein Bind Mount auf genau diesen Pfad.

So bekomme ich beide Vorteile gleichzeitig: lokal einfache Named Volumes, in Produktion volle Pfadkontrolle. Und ich muss die Compose-Datei nicht duplizieren.

Redis AOF-Persistenz

Bei Redis ist der Fehler noch tückischer, weil er oft erst nach dem ersten Crash sichtbar wird. Redis lebt primär im RAM. Ohne aktivierte Persistenz ist ein Neustart für BullMQ so, als hätte es die Queue nie gegeben. Wartende Jobs verschwinden, aktive Jobs sind weg, und der Fehler wirkt im Nachhinein wie ein unerklärlicher Schluckauf.

Deshalb würde ich Redis in Produktion nie ohne AOF laufen lassen.

redis:
  command: redis-server --appendonly yes --appendfsync everysec
  volumes:
    - ${REDIS_DATA_DIR:-redis_data}:/data

appendonly yes schreibt jede Änderung fortlaufend auf Disk. appendfsync everysec ist dabei ein guter Kompromiss aus Datensicherheit und I/O-Last. Die BullMQ-Queue überlebt damit Reboots und Container-Crashes sehr viel zuverlässiger.

Hetzner Volume als Fundament

Ein Hetzner Volume ist für mich die eigentliche Basis dieser Strategie. Technisch ist es detachbarer Block Storage, praktisch fühlt es sich an wie eine externe SSD für den Server. Ich mounte das Volume einmal an /mnt/data und lege dort alle persistenten Service-Daten ab.

Der entscheidende Vorteil zeigt sich nicht im Normalbetrieb, sondern im schlechten Tag. Wenn ich den Server neu aufsetzen muss, etwa wegen Größenwechsel, Betriebssystem-Neuinstallation oder Recovery nach einem größeren Defekt, dann muss ich meine Daten nicht exportieren und später wieder importieren. Ich hänge das Volume ab, hänge es an den neuen Server an und mounte wieder /mnt/data. Danach liegen Qdrant, Redis, Zertifikate und Logs sofort wieder an derselben Stelle.

Für 60 GB liegt das aktuell bei ungefähr 2,88 Euro pro Monat. Für eine Produktionsumgebung ist das aus meiner Sicht sehr günstige Ruhe.

restart unless-stopped

Stateful Services brauchen aus meiner Sicht fast immer restart: unless-stopped. Ohne diese Zeile reicht schon ein geplanter Reboot nach einem Kernel-Update, und Teile des Stacks bleiben aus. Dann ist nicht der Datenbestand kaputt, aber die Anwendung ist trotzdem offline.

restart: unless-stopped bedeutet: Container starten nach Crash oder Server-Neustart automatisch wieder hoch, außer ich habe sie bewusst selbst gestoppt. Genau dieses Verhalten will ich für Qdrant, Redis, Traefik, Grafana, Loki und ähnliche Infrastrukturbausteine.

Das Deployment-Runbook

Ein manueller Schritt bleibt trotzdem: Die Zielverzeichnisse müssen auf dem gemounteten Hetzner Volume existieren, bevor ich den Stack das erste Mal starte.

# Run once after mounting the Hetzner Volume at /mnt/data
mkdir -p /mnt/data/{qdrant,redis,letsencrypt,loki,grafana,ollama}

Relative Bind Mounts kann Docker oft selbst anlegen. Bei absoluten Produktionspfaden will ich mich darauf nicht verlassen. Ich erstelle die Struktur bewusst, prüfe den Mount und starte erst dann docker-compose up -d.

Server Backups als zweite Sicherungsschicht

Ein Hetzner Volume ist starke Echtzeit-Persistenz, aber für mich nicht die einzige Sicherung. Darüber lege ich noch Hetzner Server Backups. Diese Backups sind vollständige VM-Snapshots zu einem bestimmten Zeitpunkt.

Damit habe ich zwei unabhängige Ebenen. Das Volume schützt mich im laufenden Betrieb und bei Serverwechseln. Das Server Backup schützt mich bei Bedienfehlern, korrupten Deployments oder einem Problem, das erst Stunden später bemerkt wird. Für einen CX21 liegt diese zweite Schicht ungefähr bei 1,50 Euro pro Monat zusätzlich.

Übersicht über Root-Disk, Hetzner Volume und bindbare Docker-Pfade für stateful Services

Die Grafik trennt das lokale Root-Disk der VM klar vom gemounteten Hetzner Volume. Ich zeige darin, warum Docker Named Volumes standardmäßig auf dem falschen Datenträger landen können und wie der env var Pfadtrick alle stateful Services gezielt auf /mnt/data lenkt.

Für mich ist genau das die eigentliche Produktionsreife eines Docker-Setups: Nicht nur Container starten zuverlässig, sondern auch Daten landen nachweisbar auf dem Datenträger, den ich im Recovery-Fall wirklich mitnehmen kann.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: (dieser Artikel)
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

Du baust gerade ein ähnliches System und überlegst, welche Entscheidungen für dein Projekt passen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Traefik statt NGINX für einen wachsenden Docker-Compose-Stack

Traefik statt NGINX für einen wachsenden Docker-Compose-Stack

Ab acht Services im Docker-Compose-Stack wird nginx.conf zur Wartungslast. Traefik liest Service-Konfiguration direkt aus Docker-Labels, terminiert TLS automatisch über ACME und braucht keine separate Konfigurationsdatei. Warum ich gewechselt habe und wie die Konfiguration aussieht.