← Übersicht  ·  Skripte & Dateien  ·  cloud-host · Konsolidierung

Mehrere Dienste auf einem Server

Jede Anleitung in dieser Sammlung richtet einen Server ein – damit sie für sich steht. In echt wählst du eine Basis und packst mehrere Dienste sauber nebeneinander: ein Reverse Proxy, ein Stack pro Dienst, Backup pro Dienst. Mit WordPress samt Datenbank ausführlich – und der Frage, wann man doch trennt.

Stand: 30. August 2026 Docker Compose + Traefik 1 Server statt 3–5

Platzhalter einsetzen – werden in allen Befehlen unten ersetzt, nur im Browser

Wann zusammenlegen – und wann nicht

Kombiniert wird nach Plattform, nicht nach Anleitung.

Jede Anleitung installiert eine Basis (Container-Engine, k3s, OKD …) und einen Workload. Die Basis ist der teure, wartungsintensive Teil – den legst du einmal an und betreibst mehrere Workloads darauf.

Basis (ein Server)Was zusätzlich draufpasstGröße
Docker-/Podman-Host
(container-host)
WordPress, Vaultwarden, microservices-stack-Stufe 1 (nur Infra), kleine Tools, statische SeitenCPX22–CPX32
~20–36 €/Mon
CAX21 ~10,5
k3s, 1 Nodealles vom Docker-Host plus microservices-stack, bibliothek-enterprise – als Helm-Releases / ManifesteCPX32–CPX42
~36–70 €/Mon
CAX21/31 ~11–21
OKD (Single-Node / Cluster)am besten nur das – eigenes Basis-OS (SCOS / Fedora CoreOS), hoher Overheadeigener Server
  • developer-sandbox ist gehostet – nichts zu kombinieren.
  • Docker-Host und k3s auf derselben Box: technisch möglich, wird aber unsauber (beide wollen Port 80/443, cgroups, iptables). Eins nehmen.
  • Wenn du ohnehin Kubernetes lernen willst: k3s ist die universelle Basis – darauf läuft auch WordPress + Vaultwarden als simple Manifeste. Diese Anleitung bleibt bei Docker Compose, weil das für 2–6 Dienste am wenigsten bewegliche Teile hat.
Blast Radius

Alles auf einer Box heißt: eine RCE in einem WordPress-Plugin, ein durchgesickerter Container, ein voller Datenträger – und alle Dienste sind betroffen. Deshalb gehört ein Passwort-Manager / Secrets-Tresor (Vaultwarden) idealerweise auf einen eigenen Mini-Server (~4 €). Details in Abschnitt 13.

Die Architektur

Ein Proxy besitzt Port 443. Jeder Dienst ist ein eigenes Compose-Stack in seinem eigenen Netz. Nach außen reden nur Proxy und SSH (Secure Shell).

Warum keiner der Dienste einen Port auf dem Wirt veröffentlicht – und was das gemeinsame Netz damit zu tun hat.

Zeichnung aus Diagramme: Hetzner & CRC (03).

von außen: nur diese zwei Ports 80 / 443 TCP – die einzigen Ports am Wirt Reverse Proxy einziger mit ports: Docker-Netz „proxy“ – muss existieren, bevor der erste Stack startet wordpress /opt/stacks/wordpress vaultwarden /opt/stacks/vaultwarden blog /opt/stacks/blog HTTP über Dienstnamen – Dockers interner Resolver (127.0.0.11) unverschlüsselt – TLS endet beim Proxy Netz „internal“ – nur wordpress MariaDB – ein echter Netzdienst TCP 3306, nur im internal-Netz SQLite – nur eine Datei im Volume kein Netz, kein Server mountet, spricht nicht Der Trick gilt nur für echte Netzdienste: wordpress steckt in zwei Netzen – erreichbar vom Proxy, die MariaDB für niemanden sonst. vaultwarden braucht kein zweites Netz, weil seine Datenbank kein Dienst ist, sondern eine SQLite-Datei, die der Container einhängt.
Nur der Proxy veröffentlicht Ports. Alle anderen Container sind ausschließlich über ihren Dienstnamen im gemeinsamen Netz erreichbar – Docker hält dafür einen eigenen DNS-Resolver bereit, keine externe Konfiguration nötig. Die MariaDB hängt zusätzlich in einem zweiten Netz, das der Proxy nicht kennt: damit ist sie für ihren Stack da und für sonst niemanden, auch nicht für die anderen Stacks. Vaultwarden braucht das nicht – seine Datenbank ist kein Dienst, sondern eine Datei.
Edge Traefik:80/:443, Let’s Encrypt, Routing nach Hostname, Security-Header, Rate-Limit
blog. blog.seb4u.com → WordPress (Apache/PHP) + MariaDB + Redis – Netz wp_internal
vault. vault.seb4u.com → Vaultwarden – eigenes Volume [oder eigener Server]
weitere Dienste nach demselben Muster: eigenes Verzeichnis, eigenes compose.yaml, eigenes internes Netz, Traefik-Labels
Basis ein Hetzner-Server, Cloud-Firewall (nur 22 + 80 + 443), Docker Engine, ein gemeinsames Docker-Netz proxy

Die drei Regeln

  1. Genau ein Ding hört auf 80/443 – der Reverse Proxy. Jede App bekommt keine ports:, nur einen Anschluss ans gemeinsame proxy-Netz + Traefik-Labels.
  2. Ein Stack pro Dienst – eigenes Verzeichnis, eigenes compose.yaml, eigenes .env, eigene benannte Volumes, eigener DB-User. So lässt sich ein Dienst einzeln updaten, sichern, zurückrollen, löschen.
  3. Interne Dienste bleiben intern – die Datenbank hängt nur im wp_internal-Netz, nie im proxy-Netz und nie mit ports:. Nur der HTTP-Container jeder App ist in beiden Netzen.

Warum Traefik Alternativen

Traefik liest die Routing-Regeln aus den Compose-Labels – neue App = Labels dran, sonst nichts. Caddy ist noch simpler zu konfigurieren (ein Caddyfile), aber ohne Label-Automatik. Nginx Proxy Manager hat eine Web-Oberfläche, wenn du Routen lieber klickst. Alle drei können Let’s Encrypt. Das Muster unten gilt sinngemäß für alle.

Server dimensionieren

RAM (Random Access Memory) ist der Engpass, nicht CPU (Central Processing Unit). Rechne pro Dienst mit Ruhe- und Lastwert.

DienstRAM RuheRAM real (mit Cache/Last)CPU
Traefik~30 MB (Megabyte)~60 MBminimal
WordPress (Apache/PHP 8.3)~80 MB200–450 MBSpitzen 1 Kern
MariaDB 11~130 MB300–700 MB (Buffer Pool)gering
Redis~8 MB20–130 MBminimal
Vaultwarden~10 MB~30 MBminimal
System (Kernel, Docker, journald)~250 MB~400 MB
Beispiel-Summe (Traefik + WP + DB + Redis + VW + System)~0,5 GB (Gigabyte)~1,2–1,8 GB
ServerRAM / vCPUReicht für~€/Tag~€/Mon
CPX224 GB / 2Traefik + WordPress + Vaultwarden + 1–2 kleine Dienste, mit Luft~0,64~19,5
CPX328 GB / 43–6 Dienste, oder WP (WordPress) mit viel Traffic, oder ein kleiner Java-/Node-Dienst dazu~1,17~35,5
CPX4216 GB / 8WP + mehrere App-Stacks + Observability (Grafana/Loki) auf einer Box~2,28~69,5
Klein anfangen

Mit CPX22 starten. Reicht der RAM nicht mehr: hcloud server change-type <server> CPX32 --keep-disk – ein Reboot (~1 Min), Daten bleiben. Upgrade jederzeit, Downgrade nur solange die Disk kleiner-gleich passt. Swap (1–2 GB) als Puffer gegen OOM (Out Of Memory) ist auf einem geteilten Server sinnvoll, aber kein RAM-Ersatz.

Datei-Layout auf dem Server

Ein Verzeichnis pro Dienst unter /opt/stacks/. Alles, was ein Dienst braucht, liegt beisammen – leicht zu sichern, zu verschieben, zu löschen.

Server · /opt/stacks/
/opt/stacks/
├─ traefik/
│  ├─ compose.yaml
│  ├─ traefik.yaml          # statische Konfig
│  ├─ dynamic/security.yaml  # Middlewares
│  └─ acme.json             # Zertifikate  (chmod 600)
├─ wordpress/
│  ├─ compose.yaml
│  ├─ .env                  # DB-Passwoerter  (chmod 600)
│  └─ db/my.cnf             # DB-Tuning
├─ vaultwarden/
│  ├─ compose.yaml
│  └─ .env
└─ backup/
   ├─ backup.sh
   └─ .env                  # Storage-Box-Ziel

# Volumes verwaltet Docker (docker volume ls) - Daten NICHT unter /opt/stacks,
# sondern in /var/lib/docker/volumes. Das Backup-Skript zieht sie da raus.
Named Volumes statt Bind-Mounts

Benannte Volumes (wp_data:) statt Pfad-Mounts (./data:/var/www/html): Docker kümmert sich um Rechte/UID (User Identifier), das Backup-Skript spricht sie einheitlich an, und ein docker compose down -v löscht nicht versehentlich dein /opt/stacks. Ausnahme: kleine Konfig-Dateien (traefik.yaml, my.cnf) als read-only Bind-Mount.

Server + Grundhärtung

Setzt Server-Basis voraus (Konto, Token, hcloud, SSH-Key). Der Server hier wie container-host, plus die Punkte aus der Grundausstattung. Für 3–4 Stacks eher CPX32 als CPX22 (RAM).

Laptop · PowerShell
# Cloud-Firewall: nur SSH + HTTP + HTTPS
hcloud firewall create --name web-fw
hcloud firewall add-rule web-fw --direction in --protocol tcp --port 22  --source-ips 0.0.0.0/0
hcloud firewall add-rule web-fw --direction in --protocol tcp --port 80  --source-ips 0.0.0.0/0
hcloud firewall add-rule web-fw --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0

hcloud server create --name dienste --type cpx22 --image debian-12 `
  --ssh-key laptop --firewall web-fw --user-data-from-file cloud-init.yaml
Was die Schalter bedeuten

Firewall zuerst, Server danach – die Reihenfolge ist Absicht:

Schaltersteht beibewirkt
hcloud firewall create --name … zuerst Eine Hetzner-Firewall ist ein eigenständiges Objekt, kein Bestandteil des Servers: erst anlegen, dann Regeln hineinschreiben, dann an einen oder mehrere Server hängen. Sie wirkt im Netz vor dem Server – anders als ufw oder nftables, die auf dem Server selbst laufen. Deshalb kostet sie nichts an Rechenleistung und bleibt auch dann wirksam, wenn auf dem Server etwas schiefgeht. Frisch angelegt enthält sie keine Regeln und laesst damit nichts herein.
hcloud firewall add-rule <name> … je Regel Ein Aufruf je erlaubtem Port. Es gibt keine Reihenfolge und keine Priorität wie bei klassischen Firewalls: die Regeln sind eine reine Erlaubnisliste, alles Nicht-Genannte ist gesperrt. Ein Aufruf zweimal ausgeführt legt die Regel zweimal an – störend, aber harmlos.
--direction in add-rule Hetzner-Firewalls filtern nur eingehend; ausgehend bleibt offen, damit der Server Pakete und Container-Images ziehen kann.
--source-ips 0.0.0.0/0 alle drei Regeln Offen für jede IPv4-Adresse. Bei 80 und 443 ist das der Zweck – die Dienste sollen erreichbar sein. Bei Port 22 ist es eine bewusste Abwaegung: mehrere Stacks bedeuten häufigere Zugriffe von wechselnden Orten. Der Schutz liegt dann bei Key-only-Login und fail2ban aus der cloud-init-Datei, nicht bei der Firewall. Wer immer vom selben Anschluss kommt, ersetzt die Zeile besser durch --source-ips "$ip/32".
Für IPv6 fehlt hier bewusst ::/0: der Server bekommt zwar eine IPv6-Adresse, die Dienste werden aber über die IPv4-Adresse angesprochen. Sollen sie auch über IPv6 erreichbar sein, muss 0.0.0.0/0,::/0 dastehen.
--type cpx22 server create Zwei vCPU, 8 GB RAM. Der begrenzende Faktor bei mehreren Stacks ist der Arbeitsspeicher, nicht die CPU – jede Datenbank belegt ihren Puffer dauerhaft. Ab drei bis vier Stacks wird daraus cpx32.
--firewall web-fw server create Hängt die eben angelegte Firewall schon bei der Erstellung an. Deshalb steht sie oben: nachträglich angehängt stuende der Server erst ein paar Minuten ungeschützt im Netz – lang genug, um von den ueblichen Scannern gefunden zu werden.
--user-data-from-file cloud-init.yaml server create Übergibt die Datei aus dem nächsten Abschnitt. Sie läuft einmal beim allerersten Start; ein späterer Neustart führt sie nicht erneut aus. Ohne den Schalter entsteht ein leeres Debian ohne Docker und ohne den deploy-Benutzer.
` am Zeilenende PowerShell Das Zeilenfortsetzungszeichen – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in zwei unvollständige Teile.
Datei · cloud-init.yaml
#cloud-config
package_update: true
packages: [ ca-certificates, curl, fail2ban, unattended-upgrades ]
runcmd:
  # Docker Engine + Compose-Plugin
  - curl -fsSL https://get.docker.com | sh
  # non-root Betriebsuser
  - useradd -m -s /bin/bash -G docker deploy
  - install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
  - cp /root/.ssh/authorized_keys /home/deploy/.ssh/ && chown deploy:deploy /home/deploy/.ssh/authorized_keys
  # SSH haerten
  - sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/;s/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
  - systemctl restart ssh
  # 1 GB Swap als OOM-Puffer
  - fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
  - echo '/swapfile none swap sw 0 0' >> /etc/fstab
  # Docker-Logs begrenzen (sonst laeuft die Disk voll)
  - |
    cat > /etc/docker/daemon.json <<'EOF'
    { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
    EOF
  - systemctl restart docker
  - mkdir -p /opt/stacks
  - docker network create proxy
write_files:
  - path: /etc/apt/apt.conf.d/20auto-upgrades
    content: |
      APT::Periodic::Update-Package-Lists "1";
      APT::Periodic::Unattended-Upgrade "1";
Was die Zeilen bedeuten

cloud-init läuft einmal, beim allerersten Start, als root. runcmd wird der Reihe nach abgearbeitet; ein Fehler darin bricht den Rest still ab – nachsehen lässt sich das nur in /var/log/cloud-init-output.log.

ZeileZweckbewirkt
#cloud-config Zeile 1 Kein Kommentar, sondern die Kennung, an der cloud-init die Datei erkennt. Fehlt sie oder steht etwas davor, wird die ganze Datei wortlos ignoriert – und man sucht den Fehler bei Hetzner.
package_update: true Pakete Holt die Paketlisten, bevor installiert wird – das Gegenstück zu apt-get update. Ohne das kennt apt nur den Stand des Images und die Installation schlägt teils fehl.
curl -fsSL … | sh Docker Vier Schalter, die zusammengehören: -f lässt curl bei HTTP-Fehlern abbrechen, statt die Fehlerseite in die Shell zu leiten (der wichtigste), -s schaltet die Fortschrittsanzeige ab, -S holt echte Fehlermeldungen trotz -s zurück, -L folgt Weiterleitungen. Ein Skript aus dem Netz direkt auszuführen bleibt Vertrauenssache – hier gegenüber Docker.
useradd -m -s /bin/bash -G docker deploy Benutzer -m legt das Heimatverzeichnis an (ohne das existiert der Benutzer, hat aber kein Zuhause), -s setzt die Anmelde-Shell – die Vorgabe wäre je nach System /bin/sh oder gar keine, -G docker nimmt ihn in die docker-Gruppe auf. Letzteres ist gleichbedeutend mit Root-Rechten: wer Container starten darf, kann das Wirtsystem einhängen. Der Gewinn ist trotzdem echt – die tägliche Arbeit läuft nicht als root, und SSH als root ist gleich darunter abgeschaltet.
install -d -m 700 -o deploy -g deploy … SSH install statt mkdir, weil es Rechte und Eigentümer in einem Aufruf mitsetzt – sonst bräuchte es drei Befehle. Bei .ssh ist 700 Pflicht: SSH verweigert den Schlüssel, wenn andere hineinsehen dürfen.
sed -i 's/^#*PermitRootLogin.*/…/;s/^#*PasswordAuthentication.*/…/' SSH Zwei Ersetzungen in einem Aufruf, getrennt durch das Semikolon. -i schreibt direkt in die Datei. Das ^#* ist der Kniff: es trifft die Zeile auch dann, wenn sie auskommentiert ist – und genau so steht sie in der Vorgabekonfiguration. Ohne das #* fände sed nichts, meldete keinen Fehler, und der Server bliebe für Root-Anmeldung mit Passwort offen.
fallocate -l 1G /swapfileswapon Swap Vier Schritte in einer Kette: Datei anlegen, auf 600 setzen (sonst verweigert der Kernel sie), als Auslagerungsbereich formatieren, einschalten. Der fstab-Eintrag darunter sorgt dafür, dass sie einen Neustart überlebt – swapon allein gilt nur bis zum Ausschalten. Swap macht nichts schneller; er verhindert nur, dass der Kernel bei einer Speicherspitze einen Dienst abschiesst.
- | und <<'EOF' daemon.json Der senkrechte Strich ist YAML für „mehrere Zeilen wörtlich“ – nötig, weil das Here-Dokument darin selbst aus mehreren Zeilen besteht. Die Anführungszeichen um 'EOF' verhindern, dass die Shell im JSON etwas ersetzt. YAML entfernt beim Einlesen die gemeinsame Einrückung, deshalb landet das abschließende EOF am Zeilenanfang – nur dort erkennt die Shell es.
"max-size":"10m","max-file":"3" daemon.json Ohne diese Begrenzung wachsen Container-Logs unbegrenzt, bis die Platte voll ist – die häufigste Ursache für einen Server, der nach Monaten „einfach so“ stehenbleibt. Drei Dateien a 10 MB je Container. Die Einstellung gilt erst für neu erstellte Container, deshalb der Neustart des Dienstes gleich danach.
docker network create … Netz Ein gemeinsames Netz, in das sich alle Stacks hängen können – so erreicht der Reverse-Proxy die Dienste, ohne dass sie Ports auf dem Wirt veröffentlichen müssen. Es muss existieren, bevor der erste Stack startet, sonst scheitert dessen docker compose up.
write_files: Auto-Updates Schreibt Dateien, bevor runcmd läuft – unabhängig von der Reihenfolge in der Datei. Die zwei Zeilen schalten die unbeaufsichtigten Sicherheitsupdates ein; das Paket allein tut ohne sie nichts.
Tailscale statt Port 22 offen

Sauberer als „SSH von überall“: Tailscale installieren, Port 22 in der Cloud-Firewall dann nur noch aufs Tailnet oder ganz zu (siehe Grundausstattung). 80/443 bleiben für die Welt offen – die machen die Dienste ja öffentlich.

Traefik als Edge-Proxy

Einmal aufgesetzt. Danach reicht jeder App ein paar Labels.

Datei · /opt/stacks/traefik/compose.yaml
services:
  traefik:
    image: traefik:v3.3
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yaml:/etc/traefik/traefik.yaml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
      - ./acme.json:/acme.json
    networks: [ proxy ]
    labels:
      - traefik.enable=true
      # Dashboard nur ueber Basic-Auth + eigene Subdomain
      - traefik.http.routers.dash.rule=Host(`traefik.seb4u.com`)
      - traefik.http.routers.dash.entrypoints=websecure
      - traefik.http.routers.dash.tls.certresolver=le
      - traefik.http.routers.dash.service=api@internal
      - traefik.http.routers.dash.middlewares=dashauth
      - traefik.http.middlewares.dashauth.basicauth.users=admin:$$apr1$$...    # htpasswd -nbB admin '...'

networks:
  proxy:
    external: true
Datei · /opt/stacks/traefik/traefik.yaml
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint: { to: websecure, scheme: https, permanent: true }
  websecure:
    address: ":443"
    http:
      middlewares: [ secure-headers@file ]

providers:
  docker:
    exposedByDefault: false      # nur Container mit traefik.enable=true
    network: proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true

certificatesResolvers:
  le:
    acme:
      email: admin@seb4u.com
      storage: /acme.json
      tlsChallenge: {}            # kein Port 80 noetig, laeuft ueber 443

api:
  dashboard: true

log:
  level: INFO
accessLog: {}
Datei · /opt/stacks/traefik/dynamic/security.yaml
http:
  middlewares:
    secure-headers:
      headers:
        stsSeconds: 63072000
        stsIncludeSubdomains: true
        stsPreload: true
        frameDeny: true
        contentTypeNosniff: true
        referrerPolicy: strict-origin-when-cross-origin
    ratelimit:
      rateLimit: { average: 100, burst: 50 }
Server · als Benutzer deploy
cd /opt/stacks/traefik
touch acme.json && chmod 600 acme.json
# DNS: traefik. / blog. / vault.  -> A-Record auf die Server-IP (bei Cloudflare, grau/DNS only)
docker compose up -d
docker compose logs -f traefik      # auf "Configuration loaded" warten
DNS (Domain Name System) bei Cloudflare

Liegt seb4u.com schon als Full-Zone bei Cloudflare (Anleitung Domain zu Cloudflare umziehen): pro Dienst einen A-Record (blog, vault, traefik…) auf <ip>, Proxy erst grau (DNS only), bis alle Zertifikate stehen – danach optional orange mit SSL (Secure Sockets Layer)/TLS-Modus Full (strict).

Server ohne feste IP (Internet Protocol)?

Wenn du den Server per Snapshot wegwirfst und neu baust, ändert sich die IP jedes Mal. Zwei Wege, dass die Domain gültig bleibt: (a) reservierte Primary IP (0,50 €/Monat, A-Record zeigt fest drauf, siehe Hetzner Cloud Console) – oder (b) cf-dns.ps1 setzt den A-Record nach jedem up automatisch auf die neue IP (aufgerufen aus snapshot.ps1 / cloud.ps1, Felder ddnsZone / ddnsNames). Für einen dauerhaften Dienst: Primary IP.

WordPress + Datenbank

Ein Stack mit drei Containern: wordpress, db (MariaDB), redis. DB und Redis hängen nur im internen Netz.

Warum Port 80 offen sein muss, obwohl alles auf 443 umgeleitet wird.

Zeichnung aus Diagramme: Hetzner & CRC (34).

dein Server (Proxy) Let's Encrypt 1 · „Ich hätte gern ein Zertifikat für blog.seb4u.com“ HTTPS · ACME-API 2 · „Beweise, dass er dir gehört – hier ist ein Token“ 3 · legt ihn ab unter /.well-known/acme-challenge/<token> 4 · holt ihn ab – über Port 80, von mehreren Standorten der Welt Genau deshalb muss Port 80 aus dem Internet erreichbar sein. Eine Weiterleitung auf https ist erlaubt – der Prüfserver folgt ihr. Entscheidend ist nur, dass 80 nicht gefiltert wird. 5 · Zertifikatsanfrage (der private Schlüssel bleibt hier) HTTPS · ACME-API 6 · unterschriebenes Zertifikat, 90 Tage gültig 90 Tage sind Absicht. Eine kurze Laufzeit zwingt zur Automatik – und ein abhandengekommener Schlüssel ist schneller wertlos. Der Proxy erneuert ab Tag 60 selbsttätig; scheitert das unbemerkt, fällt es erst am 90. Tag auf. Ein Blick auf das Ablaufdatum gehört deshalb ins Monitoring. Die Alternative ohne Port 80: DNS-01. Statt einer Datei wird ein TXT-Record gesetzt, den die Zertifizierungsstelle abfragt. Aufwendiger – der Proxy braucht dafür Schreibrechte auf die DNS-Zone – dafür der einzige Weg zu Wildcard-Zertifikaten und zu Servern ohne offenen Web-Port. Beim Ausprobieren die Testumgebung nehmen: die Produktivumgebung erlaubt nur wenige Versuche pro Woche und Domain, dann ist eine Woche Pause.
Der Beweis ist die ganze Zeremonie. Ein Zertifikat bekommt nur, wer zeigen kann, dass ihm der Name gehört – und der einfachste Beweis ist eine Datei, die unter genau diesem Namen abrufbar ist. Deshalb hängt an Port 80 mehr als eine Umleitung.
Datei · /opt/stacks/wordpress/.env (chmod 600)
DB_NAME=wp_blog
DB_USER=wp_blog
DB_PASSWORD=# openssl rand -base64 24
WP_HOST=blog.seb4u.com
Datei · /opt/stacks/wordpress/compose.yaml
services:
  db:
    image: mariadb:11.4
    restart: unless-stopped
    command:
      - --transaction-isolation=READ-COMMITTED   # von WordPress empfohlen
      - --binlog-format=ROW
      - --innodb-buffer-pool-size=512M
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    environment:
      MARIADB_DATABASE: ${DB_NAME}
      MARIADB_USER: ${DB_USER}
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_RANDOM_ROOT_PASSWORD: "yes"
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD","healthcheck.sh","--connect","--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [ wp_internal ]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: [ "redis-server","--maxmemory","128mb","--maxmemory-policy","allkeys-lru","--save","" ]
    networks: [ wp_internal ]

  wordpress:
    image: wordpress:6-php8.3-apache
    restart: unless-stopped
    depends_on:
      db: { condition: service_healthy }
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${DB_NAME}
      WORDPRESS_DB_USER: ${DB_USER}
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST','redis');
        define('WP_REDIS_PORT',6379);
        define('WP_CACHE', true);
        define('FS_METHOD','direct');
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      - wp_data:/var/www/html
    networks: [ wp_internal, proxy ]
    labels:
      - traefik.enable=true
      - traefik.http.routers.wp.rule=Host(`${WP_HOST}`)
      - traefik.http.routers.wp.entrypoints=websecure
      - traefik.http.routers.wp.tls.certresolver=le
      - traefik.http.routers.wp.middlewares=ratelimit@file
      - traefik.http.services.wp.loadbalancer.server.port=80

volumes:
  db_data:
  wp_data:

networks:
  wp_internal:
  proxy:
    external: true
Server · /opt/stacks/wordpress
docker compose up -d
docker compose logs -f wordpress        # einmalige Installation im Container

# Redis-Object-Cache aktivieren (WP-CLI im laufenden Container):
docker compose exec -u www-data wordpress \
  bash -c 'wp plugin install redis-cache --activate && wp redis enable'

Aufrufen: https://blog.seb4u.com/wp-admin → Sprache, Titel, Admin-Konto (Benutzername nicht admin), 2FA-Plugin.

DB-Tuning optional, aber sinnvoll

Statt der command:-Zeilen eine my.cnf mounten – übersichtlicher, wenn mehr Parameter dazukommen:

Datei · /opt/stacks/wordpress/db/my.cnf
[mariadb]
transaction-isolation      = READ-COMMITTED
binlog-format              = ROW
character-set-server       = utf8mb4
collation-server           = utf8mb4_unicode_ci
innodb-buffer-pool-size    = 512M      # ~25-40 % vom RAM, den du der DB goennst
innodb-flush-log-at-trx-commit = 2     # etwas schneller, minimal weniger crash-sicher
max-connections            = 60
slow-query-log             = 1
long-query-time            = 1

Mount: - ./db/my.cnf:/etc/mysql/conf.d/z-tuning.cnf:ro im db-Service, command: dann weglassen.

Warum lokale MariaDB

  • WordPress ist bei jedem Seitenaufruf sehr gesprächig mit der DB. Über Netzwerk (managed DB, anderer Host) sammelt sich Latenz → spürbar langsamer.
  • Eine im Netz erreichbare MySQL ist eine Angriffsfläche. Im internen Docker-Netz mit eigenem User: nicht.
  • MariaDB statt Oracle-MySQL: Drop-in, in der Praxis für WordPress etwas fixer, große Verbreitung. Redis als Object-Cache nimmt der DB einen Großteil der Wiederhol-Queries ab – auf einem geteilten Server wichtig.
  • Hetzner hat keine managed Datenbank. Externe managed DB = Latenz + Extra-Kosten, für eine kleine Site nicht sinnvoll.

Umzug einer bestehenden Site z. B. von easyname / CloudPanel

alte Umgebung → neuer Server
# alt: DB + wp-content sichern
wp db export dump.sql          # oder: mysqldump --single-transaction ... > dump.sql
tar czf wp-content.tar.gz wp-content

# neu: einspielen (Stack laeuft schon)
docker compose exec -T db sh -c 'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE' < dump.sql
docker run --rm -v wordpress_wp_data:/v -v "$PWD":/b alpine \
  sh -c 'cd /v/wp-content && tar xzf /b/wp-content.tar.gz --strip-components=1'

# URLs umschreiben, falls die Domain wechselt
docker compose exec -u www-data wordpress \
  wp search-replace 'https://alt.example' 'https://blog.seb4u.com' --all-tables
Was die Schalter bedeuten

Ein Umzug in drei Teilen – Datenbank, Dateien, URLs. Der dritte Teil ist der, den man beim ersten Mal vergisst:

Schaltergehört zubewirkt
docker compose exec -T db … < dump.sql Datenbank -T schaltet die Pseudo-Terminal-Zuweisung ab. Ohne das fügt Docker Steuerzeichen in den Datenstrom ein und die eingelesene SQL-Datei kommt verfälscht an – oder der Aufruf scheitert ganz, wenn kein Terminal vorhanden ist. Bei jedem exec, das eine Datei einliest oder ausgibt, gehört es dazu.
sh -c 'mariadb -u$MARIADB_USER …' Datenbank Die einfachen Anführungszeichen sind entscheidend: die Variablen sollen im Container aufgelöst werden, wo die Zugangsdaten als Umgebungsvariablen liegen – nicht auf dem Host, wo sie leer wären. Bei doppelten stünde das Passwort außerdem in der Prozessliste des Hosts. Der Wert folgt bei -u und -p ohne Leerzeichen – so verlangt es MariaDB.
--single-transaction Kommentar oben Beim Sichern der alten Seite: sichert alle Tabellen aus einem konsistenten Stand, ohne sie zu sperren. Ohne den Schalter liest der Dump Tabelle für Tabelle, während die Seite weiterläuft – das Ergebnis kann Beiträge enthalten, deren Metadaten fehlen.
docker run --rm -v wordpress_wp_data:/v -v "$PWD":/b alpine Dateien Der übliche Weg, in ein Docker-Volume zu schreiben, ohne einen laufenden Container zu bemühen: ein Wegwerf-Container hängt das Volume unter /v und das aktuelle Verzeichnis unter /b ein, packt aus und verschwindet (--rm). "$PWD" muss der absolute Pfad sein – Docker akzeptiert bei -v keine relativen Angaben.
tar xzf … --strip-components=1 Dateien Entfernt beim Auspacken die oberste Verzeichnisebene aus jedem Pfad. Das Archiv wurde mit tar czf wp-content.tar.gz wp-content erstellt, trägt also überall wp-content/ vorweg – und wird nach /v/wp-content ausgepackt. Ohne --strip-components=1 entstünde wp-content/wp-content/, und WordPress fände weder Themes noch Uploads.
-u www-data search-replace Führt WP-CLI als der Benutzer aus, dem die Dateien gehören. Als root ausgeführt legt WordPress Dateien an, die dem Webserver anschließend nicht mehr gehören – ein Fehler, der erst Tage später als unerklärliche Rechteprobleme auffällt.
wp search-replace 'https://alt…' 'https://neu…' URLs WordPress speichert die vollständige Adresse in unzähligen Datensätzen – nach einem Umzug zeigen Bilder, Menüs und interne Links sonst weiter auf die alte Domain. Und der Grund, warum es dieses Werkzeug gibt statt eines sed über den Dump: viele Werte liegen als serialisiertes PHP vor, in dem die Länge jeder Zeichenkette mitgespeichert ist. Wer den Text mit einem Editor ersetzt, macht die Längenangabe falsch und WordPress verwirft den Wert stillschweigend. wp search-replace rechnet sie neu.
--all-tables search-replace Bezieht auch Tabellen ein, die nicht mit dem WordPress-Präfix beginnen – also die von Plugins. Ohne den Schalter bleiben genau dort die alten Adressen stehen. Ein Probelauf lohnt: --dry-run zeigt, wie viele Ersetzungen es wären, ohne etwas zu ändern.

Alternativ ohne CLI (Command-Line Interface): Plugin Duplicator oder All-in-One WP Migration – exportiert Site + DB als ein Paket, im neuen WP wieder importieren.

Datenbank: geteilt oder pro Dienst

Zwei brauchbare Muster. Faustregel: bis ~3 Dienste je eigene DB, ab dann eine geteilte Instanz – wegen RAM.

A – eine DB-Instanz pro Dienst Standard

  • Jeder Stack bringt sein eigenes db-Container mit (wie WordPress oben).
  • Vorteil: vollständig isoliert – eigene Version, eigenes Tuning, eigenes Backup, lässt sich einzeln löschen. Ein „DROP“ im einen trifft den anderen nie.
  • Nachteil: jede MariaDB kostet ~130 MB Grund + Buffer Pool. Fünf Dienste = fünfmal Overhead.

B – eine geteilte DB-Instanz, mehrere Datenbanken

Datei · /opt/stacks/db-shared/compose.yaml
services:
  mariadb:
    image: mariadb:11.4
    restart: unless-stopped
    command: [ --innodb-buffer-pool-size=768M, --character-set-server=utf8mb4,
               --collation-server=utf8mb4_unicode_ci, --transaction-isolation=READ-COMMITTED ]
    environment:
      MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root
    volumes:
      - shared_db:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d:ro   # .sql beim ersten Start
    networks: [ db_net ]
    secrets: [ db_root ]
volumes:
  shared_db:
networks:
  db_net:
    name: db_net
secrets:
  db_root:
    file: ./db_root.txt
Datei · /opt/stacks/db-shared/init/10-apps.sql
CREATE DATABASE IF NOT EXISTS wp_blog   CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER IF NOT EXISTS 'wp_blog'@'%'   IDENTIFIED BY 'PW_A';
CREATE USER IF NOT EXISTS 'nextcloud'@'%' IDENTIFIED BY 'PW_B';

GRANT ALL PRIVILEGES ON wp_blog.*   TO 'wp_blog'@'%';     # nur die eigene DB
GRANT ALL PRIVILEGES ON nextcloud.* TO 'nextcloud'@'%';
FLUSH PRIVILEGES;
  • Die App-Stacks bekommen dann kein eigenes db-Container, sondern hängen zusätzlich im externen db_net und setzen WORDPRESS_DB_HOST: mariadb.
  • Jede App bekommt eigene DB + eigenen User, der nur auf seine DB GRANT hat – das ist die Isolationsgrenze.
  • Vorteil: ein DB-Prozess, ein Buffer Pool, ~300–500 MB gespart bei 5 Apps. Ein Backup-Job dumpt alle DBs.
  • Nachteil: gemeinsame Version + gemeinsames Tuning. Ein Neustart trifft alle. Ein durchgesickertes Root-Passwort öffnet alle DBs. Root-Passwort daher als Docker-Secret, nicht als Env.

Postgres vs. MySQL/MariaDB

Manche Dienste wollen Postgres (Vaultwarden optional, Gitea, viele neuere Apps), manche MySQL/MariaDB (WordPress, Nextcloud klassisch). Dann brauchst du beide Engines – nicht mischen in einer Instanz. Praktisch: eine geteilte MariaDB und eine geteilte Postgres, je als eigener Mini-Stack. Vaultwarden läuft aber gut mit seiner eingebauten SQLite – für einen Ein-Personen-Tresor kein DB-Server nötig.

Empfehlung

Fang mit Muster A an (DB im App-Stack). Wechsle zu B, wenn du 4+ DB-gestützte Dienste hast oder der RAM knapp wird. Der Wechsel ist pro App ein mysqldump → in die geteilte Instanz einspielen → DB_HOST umbiegen.

Vaultwarden dazu

Gleiche Mechanik, eigener Stack. Lies zuerst Abschnitt 13 – für einen Passwort-Tresor ist ein eigener Server oft die bessere Wahl.

Datei · /opt/stacks/vaultwarden/compose.yaml
services:
  vaultwarden:
    image: vaultwarden/server:1.32-alpine
    restart: unless-stopped
    environment:
      DOMAIN: https://vault.seb4u.com
      SIGNUPS_ALLOWED: "false"          # nach dem ersten Konto: aus
      INVITATIONS_ALLOWED: "true"
      ADMIN_TOKEN: ${ADMIN_TOKEN}       # Argon2-Hash, siehe unten
      PUSH_ENABLED: "false"
    volumes:
      - vw_data:/data
    networks: [ proxy ]
    labels:
      - traefik.enable=true
      - traefik.http.routers.vw.rule=Host(`vault.seb4u.com`)
      - traefik.http.routers.vw.entrypoints=websecure
      - traefik.http.routers.vw.tls.certresolver=le
      - traefik.http.routers.vw.middlewares=secure-headers@file
      - traefik.http.services.vw.loadbalancer.server.port=80
volumes:
  vw_data:
networks:
  proxy:
    external: true
Server · /opt/stacks/vaultwarden
# Admin-Token als Argon2-Hash erzeugen (nicht im Klartext ablegen):
docker run --rm -it vaultwarden/server /vaultwarden hash
# Ausgabe in .env:  ADMIN_TOKEN='$argon2id$v=19$...'
docker compose up -d

Danach https://vault.seb4u.com im Browser, Konto anlegen, dann SIGNUPS_ALLOWED auf "false" und docker compose up -d. Admin-Panel: /admin. Die offiziellen Bitwarden-Apps zeigen in den Einstellungen auf die Server-URL – Details in der Anleitung Vaultwarden auf Hetzner.

Weitere Dienste nach Muster

Jeder neue Dienst: Verzeichnis anlegen, compose.yaml mit dem Label-Block, docker compose up -d, DNS-Record. Das ist alles.

Muster · /opt/stacks/<dienst>/compose.yaml
services:
  app:
    image: <image>
    restart: unless-stopped
    environment: { ... }
    volumes: [ app_data:/data ]
    networks: [ internal, proxy ]      # internal nur wenn es ein DB-/Cache-Backend hat
    labels:
      - traefik.enable=true
      - traefik.http.routers.<dienst>.rule=Host(`<dienst>.seb4u.com`)
      - traefik.http.routers.<dienst>.entrypoints=websecure
      - traefik.http.routers.<dienst>.tls.certresolver=le
      - traefik.http.routers.<dienst>.middlewares=secure-headers@file
      - traefik.http.services.<dienst>.loadbalancer.server.port=<container-port>
volumes: { app_data: }
networks:
  internal: {}
  proxy: { external: true }
DienstWofürFalle
NextcloudDateien, Kalender, Kontaktebraucht DB (MariaDB/Postgres) + Redis, overwriteprotocol=https + trusted_domains setzen
Gitea / Forgejoeigenes GitSSH-Port des Containers separat (z. B. 2222) – braucht eine eigene Firewall-Regel
Uptime KumaMonitoring der anderen Diensteklein, keine DB, sollte von außen prüfen, nicht nur localhost
VaultwardenPasswörterBlast Radius – eigener Server erwägen
n8n / Grafana / MetabaseAutomatisierung / DashboardsRAM-hungrig (Node/JVM) – Servergröße neu rechnen
statische SeiteLanding, Dokunginx:alpine + Volume, ein Label-Block, fertig

Backup pro Dienst

Nicht „der Server“, sondern je Dienst: Volume-Archiv + konsistenter DB-Dump, dann off-site. So lässt sich ein Dienst wiederherstellen, ohne die anderen anzufassen.

Datei · /opt/stacks/backup/backup.sh
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F_%H%M)
DEST="/opt/backups/$STAMP"
mkdir -p "$DEST"

dump_mariadb() {  # $1 = compose-projektname, $2 = service
  docker compose -p "$1" exec -T "$2" sh -c \
    'exec mariadb-dump --single-transaction --routines --triggers \
       -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$DEST/$1-db.sql.gz"
}
archive_volume() {  # $1 = volume-name
  docker run --rm -v "$1":/v -v "$DEST":/b busybox \
    tar czf "/b/$1.tar.gz" -C /v .
}

# --- WordPress ---
dump_mariadb  wordpress db
archive_volume wordpress_wp_data

# --- Vaultwarden (SQLite -> einfach das Volume, Dienst kurz pausieren fuer Konsistenz) ---
docker compose -p vaultwarden stop
archive_volume vaultwarden_vw_data
docker compose -p vaultwarden start

# --- off-site: Hetzner Storage Box per rsync/ssh ---
rsync -a --delete "$DEST/" "$SB_USER@$SB_HOST:backups/$STAMP/"

# --- lokale Aufbewahrung: 7 Tage ---
find /opt/backups -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
echo "ok: $STAMP"
Server · einrichten
chmod +x /opt/stacks/backup/backup.sh
# Storage Box: ssh-key hinterlegen, dann als cron (03:15 taeglich)
( crontab -l 2>/dev/null; echo '15 3 * * * SB_USER=u123 SB_HOST=u123.your-storagebox.de /opt/stacks/backup/backup.sh >> /var/log/backup.log 2>&1' ) | crontab -
Die Hebel im Skript

Kurz, aber dicht – fast jede Zeile enthält einen Schalter, ohne den das Backup still unbrauchbar wäre:

KonstruktStellebewirkt
#!/usr/bin/env bash Zeile 1 Die Shebang-Zeile bestimmt, womit die Datei ausgeführt wird. /usr/bin/env bash statt /bin/bash sucht bash über den Suchpfad – portabler, weil bash nicht auf jedem System am selben Ort liegt. bash und nicht sh, weil -o pipefail in der nächsten Zeile eine bash-Erweiterung ist. Wirksam wird die Zeile nur, wenn die Datei ausführbar ist (chmod +x, siehe darunter).
mkdir -p "$DEST" Kopf -p legt fehlende übergeordnete Verzeichnisse gleich mit an – und meldet keinen Fehler, wenn das Verzeichnis schon existiert. Ohne das würde set -e aus der Zeile darüber das Skript beim zweiten Lauf sofort beenden.
Anführungszeichen um "$DEST" überall Nicht Kosmetik: ohne sie zerlegt die Shell einen Wert mit Leerzeichen in mehrere Argumente. Hier enthalten die Werte zwar keine, aber die Gewohnheit ist der Grund, warum solche Skripte auch dann noch funktionieren, wenn später ein Pfad mit Leerzeichen dazukommt.
set -euo pipefail Kopf Drei Absicherungen: -e bricht beim ersten Fehler ab, -u bei einer ungesetzten Variablen (hier wichtig für $SB_USER, das aus dem cron-Eintrag kommt), -o pipefail lässt eine Pipe scheitern, wenn irgendein Glied scheitert. Genau das braucht die Dump-Zeile: ohne pipefail meldet sie Erfolg, sobald gzip zufrieden ist – auch wenn davor mariadb-dump abgebrochen ist und nur eine leere Datei ankam.
STAMP=$(date +%F_%H%M) Kopf %F ist 2026-09-02, also Jahr zuerst. Damit sortiert die Verzeichnisliste automatisch chronologisch – und die Aufbewahrungszeile ganz unten braucht keine Datumslogik.
docker compose -p "$1" exec -T dump_mariadb -T schaltet die Pseudo-Terminal-Zuweisung ab. Ohne das fügt Docker Steuerzeichen und Zeilenumbrüche in den Datenstrom ein – der Dump läuft scheinbar durch und lässt sich später nicht einspielen. Aus einem cron-Lauf heraus, wo gar kein Terminal existiert, scheitert der Aufruf ohne -T ganz.
--single-transaction mariadb-dump Sichert alle Tabellen aus einem konsistenten Stand heraus, ohne sie zu sperren. Ohne den Schalter liest der Dump Tabelle für Tabelle, während die Seite weiterläuft – das Ergebnis kann Beiträge enthalten, deren Metadaten fehlen. Der Fehler fällt beim Sichern nie auf, sondern erst beim Zurückspielen.
--routines --triggers mariadb-dump Nimmt gespeicherte Prozeduren und Trigger mit – beides ist standardmässig nicht im Dump. Bei WordPress meist leer, bei Plugins mit eigener Logik nicht.
sh -c 'exec mariadb-dump … -u"$MARIADB_USER"' dump_mariadb Die einfachen Anführungszeichen sind hier entscheidend: die Variablen sollen im Container aufgelöst werden, wo die Zugangsdaten als Umgebungsvariablen liegen – nicht auf dem Host, wo sie leer wären. Bei doppelten Anführungszeichen stünde das Passwort außerdem in der Prozessliste des Hosts. Das exec ersetzt die Shell durch mariadb-dump, sodass dessen Exit-Code unverfälscht bei pipefail ankommt. Bei -u und -p folgt der Wert direkt ohne Leerzeichen – das ist bei MySQL/MariaDB so vorgeschrieben.
docker run --rm -v "$1":/v -v "$DEST":/b busybox archive_volume Der übliche Weg, an ein Docker-Volume heranzukommen: ein Wegwerf-Container hängt es ein, packt es und verschwindet (--rm). Von außen liegen Volumes zwar unter /var/lib/docker/volumes, aber darauf sollte man nicht zugreifen.
tar czf "/b/$1.tar.gz" -C /v . archive_volume -C /v wechselt vor dem Packen das Verzeichnis, das abschließende . packt dessen Inhalt. Dadurch enthält das Archiv relative Pfade und lässt sich in ein beliebiges Volume zurückspielen. Ohne -C stuende v/ in jedem Pfad – und beim Entpacken landete alles eine Ebene zu tief.
docker compose -p vaultwarden stopstart Vaultwarden SQLite hat keinen Dump-Befehl von außen; deshalb wird der Dienst für ein paar Sekunden angehalten. stop/start, nicht down/up: die Container bleiben bestehen, nur die Prozesse pausieren. Ein down würde das Netz und die Container entfernen.
rsync -a --delete off-site -a bündelt Rekursion, Rechte, Zeitstempel und Symlinks. --delete löscht am Ziel, was in der Quelle fehlt – hier ungefährlich, weil in ein neues Verzeichnis mit Zeitstempel geschrieben wird. Stünde am Ziel ein fester Pfad, würde derselbe Schalter alle älteren Sicherungen mitnehmen. Der Schrägstrich hinter "$DEST/" ist ebenfalls bedeutungstragend: mit ihm wird der Inhalt kopiert, ohne ihn das Verzeichnis selbst.
find … -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + Aufbewahrung Vier Einschränkungen, die zusammen verhindern, dass die Zeile zu viel löscht: -maxdepth 1 bleibt in der obersten Ebene, -type d trifft nur Verzeichnisse, -mtime +7 nur solche älter als sieben Tage. Das + am Ende sammelt die Treffer und ruft rm einmal mit allen auf, statt einmal pro Treffer (mit \;) – schneller und derselbe Effekt.
( crontab -l 2>/dev/null; echo '…' ) | crontab - einrichten Die übliche Art, einen cron-Eintrag hinzuzufügen, ohne einen Editor zu öffnen: bestehende Tabelle ausgeben, neue Zeile anhängen, alles zurückschreiben. Das 2>/dev/null fängt den Fall ab, dass es noch gar keine Tabelle gibt – sonst bräche die Fehlermeldung die Kette. Zweimal ausgeführt steht der Eintrag doppelt darin; vorher crontab -l prüfen.
>> /var/log/backup.log 2>&1 cron-Zeile Hängt Ausgabe und Fehler an dieselbe Datei. Die Reihenfolge ist entscheidend: 2>&1 muss hinter der Umleitung stehen, sonst zeigt der Fehlerkanal noch auf das alte Ziel. Ohne die Zeile schickte cron alles per lokaler Mail – die auf einem solchen Server niemand liest.
Kein Block-Snapshot der laufenden DB

Ein Hetzner-Snapshot / tar des DB-Volumes während MariaDB schreibt gibt dir mit hoher Wahrscheinlichkeit eine korrupte Kopie. Immer mariadb-dump --single-transaction (logisch, konsistent). SQLite (Vaultwarden) vorher kurz stoppen oder .backup nutzen. Der Server-Snapshot ist für schnelles Wiederaufsetzen des Systems, nicht für die Nutzdaten.

Restore-Probe einmal wirklich machen

Server · WordPress aus Backup
cd /opt/stacks/wordpress
docker compose down            # Volumes bleiben
docker volume rm wordpress_wp_data wordpress_db_data
docker volume create wordpress_wp_data && docker volume create wordpress_db_data
docker run --rm -v wordpress_wp_data:/v -v /opt/backups/<stamp>:/b busybox \
  tar xzf /b/wordpress_wp_data.tar.gz -C /v
docker compose up -d db && sleep 20
gunzip -c /opt/backups/<stamp>/wordpress-db.sql.gz | \
  docker compose exec -T db sh -c 'mariadb -u$MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'
docker compose up -d
Was die Schalter bedeuten

Beim Zurückspielen zählt vor allem die Reihenfolge – jeder Schritt setzt den vorigen voraus:

SchalterSchrittbewirkt
docker compose down 1 Hält die Container an und entfernt sie samt Netz – Volumes bleiben. Nur down -v würde sie mitlöschen, und genau deshalb steht der Schalter hier nicht: das Löschen soll gleich gezielt und sichtbar passieren, nicht als Nebenwirkung.
docker volume rmdocker volume create 2 Bewusst leeren statt darüberkopieren. Würde man das Archiv in ein befülltes Volume auspacken, blieben Dateien stehen, die es in der Sicherung nicht mehr gibt – und die Wiederherstellung wäre eine Mischung aus altem und neuem Stand, die nirgends so je existiert hat. Diese Zeile ist der Punkt ohne Rückkehr: ab hier gibt es nur noch das, was im Backup liegt.
docker run --rm -v vol:/v -v /opt/backups/<stamp>:/b busybox 3 Ein Wegwerf-Container hängt das leere Volume unter /v und das Backup-Verzeichnis unter /b ein, packt aus und verschwindet (--rm). Der übliche Weg, an ein Volume zu kommen, ohne einen laufenden Container zu bemühen.
tar xzf /b/….tar.gz -C /v 3 -C /v wechselt vor dem Auspacken das Zielverzeichnis. Das Archiv wurde mit -C /v . erstellt, enthält also relative Pfade – hier passt beides zusammen. Ein Archiv mit führendem Verzeichnisnamen bräuchte stattdessen --strip-components=1.
docker compose up -d db && sleep 20 4 Nur die Datenbank, nicht der ganze Stack – WordPress soll noch nicht starten und in eine leere Datenbank schreiben. Die zwanzig Sekunden geben MariaDB Zeit, sich beim ersten Start einzurichten; früher zugegriffen scheitert der Import mit connection refused. Wer es genau nimmt, ersetzt das sleep durch eine Schleife auf docker compose exec db mariadb-admin ping.
gunzip -c … | docker compose exec -T db … 5 -c schreibt nach stdout, statt die Datei zu entpacken – das Archiv bleibt unangetastet. -T schaltet die Pseudo-Terminal-Zuweisung ab; ohne das fügt Docker Steuerzeichen in den Datenstrom ein und der Import kommt verfälscht an, oder der Aufruf scheitert ganz, weil kein Terminal da ist.
sh -c 'mariadb -u$MARIADB_USER …' 5 Die einfachen Anführungszeichen sind entscheidend: die Variablen sollen im Container aufgelöst werden, wo die Zugangsdaten als Umgebungsvariablen liegen. Bei doppelten wären sie leer – und das Passwort stünde in der Prozessliste des Hosts.
docker compose up -d 6 Jetzt erst der Rest des Stacks. Ohne Dienstnamen startet up alles, was in der compose-Datei steht – die Datenbank läuft schon und bleibt unberührt.

Betrieb: Updates, Logs, Monitoring

Vom Laptop: dienste-stack.ps1 im cloud-host-Ordner

Alle Stacks in einem Rutsch an/aus, per SSH – in der richtigen Reihenfolge (Traefik + geteilte DB zuerst hoch, zuletzt runter):

Laptop · PowerShell
.\scripts\dienste-stack.ps1 status                    # was laeuft, Container, RAM/CPU, Disk
.\scripts\dienste-stack.ps1 up                        # jeder Stack: docker compose up -d
.\scripts\dienste-stack.ps1 down                      # jeder Stack: docker compose down (Volumes bleiben)
.\scripts\dienste-stack.ps1 stop        # / start  - schneller, ohne Container wegzuwerfen
.\scripts\dienste-stack.ps1 pull                      # pull + up -d fuer alle, dann image prune
.\scripts\dienste-stack.ps1 logs traefik              # Logs eines Stacks folgen
.\scripts\dienste-stack.ps1 up   -Only wordpress,vaultwarden
.\scripts\dienste-stack.ps1 down -Only wordpress

Oben in der Datei: $Server (SSH-Ziel bzw. ~/.ssh/config-Alias), $StacksDir, $UpOrder, $Exclude. Findet die Stacks selbst (/opt/stacks/*/compose.yaml). Server aus? Erst .\scripts\snapshot.ps1 up bzw. .\scripts\cloud.ps1 up.

Updates – pro Stack, kontrolliert

Server · je Verzeichnis (oder dienste-stack.ps1 pull)
cd /opt/stacks/wordpress
docker compose pull
docker compose up -d
docker image prune -f
  • Image-Tags pinnen (mariadb:11.4, nicht latest) – dann bringt pull nur Patch-Updates, keine bösen Überraschungen.
  • Watchtower (Auto-Update aller Container) auf einem Multi-Dienst-Server eher nicht – ein kaputtes Upstream-Image legt dann nachts alles lahm. Lieber diun (nur Benachrichtigung „neues Image da“) + von Hand.
  • Vor größeren Updates (MariaDB-Major, WordPress-Major): Backup läuft lassen, dann updaten.

Logs & Platz

  • Docker-Log-Limit ist in daemon.json gesetzt (10 MB × 3 pro Container). journald zusätzlich begrenzen: journalctl --vacuum-time=14d bzw. SystemMaxUse=500M.
  • df -h und docker system df ins Monitoring – ein voller Datenträger nimmt alle Dienste mit.
  • docker compose logs -f --tail=100 <service> für Fehlersuche.

Monitoring

  • Uptime Kuma als eigener kleiner Stack – prüft jede öffentliche URL (Uniform Resource Locator) von außen, schickt bei Ausfall Telegram/E-Mail. Am besten auf einem anderen Host (sonst meldet es sich nicht, wenn der Server weg ist) – z. B. der kostenlose Tier eines zweiten Anbieters, oder daheim.
  • netdata oder beszel für RAM/CPU/Disk-Verlauf pro Container – zeigt dir, welcher Dienst zu groß wird, bevor OOM zuschlägt.
  • Hetzner-Verbrauchswarnung: es gibt kein Ausgabenlimit – wenn der Server 24/7 läuft, ist das der teuerste Fall. Snapshot/Restore (Anleitung) gilt auch hier, wenn der Server nur zeitweise gebraucht wird.
Die Realität eines geteilten Servers

Ein Kernel-Update mit Reboot, ein voller Datenträger, eine OOM-Situation – das trifft alle Dienste gleichzeitig. Plane Wartungsfenster, halte Backups aktuell, und hänge nichts dran, dessen Ausfall wirklich weh tut, ohne es vorher durchgespielt zu haben.

Wann doch trennen

Konsolidieren ist der Normalfall. Diese Dinge gehören trotzdem auf einen eigenen Server:

SignalWarum eigener Server
Passwort-Manager / Secrets-Tresor (Vaultwarden, Passbolt)größter Schaden bei Kompromittierung, will minimale Angriffsfläche, keine PHP-/Node-Nachbarn. Ein CPX22 dafür kostet ~19,5 € (CAX11 ARM ~6 €, falls verfügbar).
Kunden-/DSGVO-Daten vs. BastelkramTrennung der Verantwortungsbereiche. Ein kompromittierter Test-Blog soll nicht neben Personendaten liegen.
Anderer Uptime-Anspruch (Produktiv-Shop vs. Test-Umgebung)du willst den Shop nicht rebooten, weil die Testkiste ein Update braucht.
„Noisy Neighbor“ (CI-Runner, Video-Encoding, große JVM, ML)zieht regelmäßig CPU/RAM und macht die anderen Dienste ruckelig.
Mehr als ~6–8 Dienstezweite Box oder Wechsel auf k3s (dann wird Scheduling / Ressourcenlimit zum Werkzeug statt zum Ratespiel).
Getrennte Mandanten / Kundenje Kunde eigener Server = klare Abrechnung, klare Grenze, unabhängig kündbar.

+ Kostenvergleich

Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.

VarianteServer~€/Tag~€/Mon
3 einzelne AnleitungenCPX22 Container-Host + CPX22 WordPress + CPX22 Vaultwarden~1,92~58,5
1 konsolidiertCPX32 (alles zusammen)~1,17~35,5
konsolidiert, Tresor separat (empfohlen)CPX32 für Web + CPX22 nur Vaultwarden~1,81~55
zeitweise (Snapshot/Restore)CPX32 nur bei Bedarf an, sonst Snapshot~0,03 + Laufzeit~0,5 + Laufzeit

Die Ersparnis in Euro ist moderat. Der größere Gewinn: eine Wartungsoberfläche – ein OS (Betriebssystem) patchen, ein Backup-Lauf prüfen, ein Monitoring, ein Firewall-Regelsatz – statt drei- bis fünffach.

+ Podman statt Docker

Die Beispiele nutzen Docker Compose. Mit Podman:

  • podman compose (oder podman-compose) versteht dieselben compose.yaml – Netze, Volumes, Labels identisch. Traefik liest die Labels über den Podman-Socket (podman system service).
  • Sauberer für den Dauerbetrieb: Quadlet – je Dienst eine .container-Datei in /etc/containers/systemd/, dann sind es echte systemd-Units (systemctl restart wordpress). Ein .network fürs proxy-Netz.
  • Rootless ist der Podman-Vorteil für einen geteilten Server – jeder Dienst als eigener unprivilegierter User, Container-Ausbruch trifft nur diesen User. Dafür kann Port 80/443 nicht direkt gebunden werden (net.ipv4.ip_unprivileged_port_start=80 setzen oder Traefik als root/rootful).
  • Der container-host-Guide hat den Podman/Docker-Umschalter im Detail.
⌂ Cockpit