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 draufpasst | Größe |
|---|---|---|
| Docker-/Podman-Host (container-host) | WordPress, Vaultwarden, microservices-stack-Stufe 1 (nur Infra), kleine Tools, statische Seiten | CPX22–CPX32 ~20–36 €/Mon CAX21 ~10,5 |
| k3s, 1 Node | alles vom Docker-Host plus microservices-stack, bibliothek-enterprise – als Helm-Releases / Manifeste | CPX32–CPX42 ~36–70 €/Mon CAX21/31 ~11–21 |
| OKD (Single-Node / Cluster) | am besten nur das – eigenes Basis-OS (SCOS / Fedora CoreOS), hoher Overhead | eigener 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.
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).
:80/:443, Let’s Encrypt,
Routing nach Hostname, Security-Header, Rate-Limit
blog.seb4u.com →
WordPress (Apache/PHP) + MariaDB + Redis – Netz wp_internal
vault.seb4u.com →
Vaultwarden – eigenes Volume [oder eigener Server]
compose.yaml, eigenes internes Netz, Traefik-Labels
proxy
Die drei Regeln
- Genau ein Ding hört auf 80/443 – der Reverse Proxy. Jede App
bekommt keine
ports:, nur einen Anschluss ans gemeinsameproxy-Netz + Traefik-Labels. - 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. - Interne Dienste bleiben intern – die Datenbank hängt nur im
wp_internal-Netz, nie improxy-Netz und nie mitports:. 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.
| Dienst | RAM Ruhe | RAM real (mit Cache/Last) | CPU |
|---|---|---|---|
| Traefik | ~30 MB (Megabyte) | ~60 MB | minimal |
| WordPress (Apache/PHP 8.3) | ~80 MB | 200–450 MB | Spitzen 1 Kern |
| MariaDB 11 | ~130 MB | 300–700 MB (Buffer Pool) | gering |
| Redis | ~8 MB | 20–130 MB | minimal |
| Vaultwarden | ~10 MB | ~30 MB | minimal |
| 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 | – |
| Server | RAM / vCPU | Reicht für | ~€/Tag | ~€/Mon |
|---|---|---|---|---|
CPX22 | 4 GB / 2 | Traefik + WordPress + Vaultwarden + 1–2 kleine Dienste, mit Luft | ~0,64 | ~19,5 |
CPX32 | 8 GB / 4 | 3–6 Dienste, oder WP (WordPress) mit viel Traffic, oder ein kleiner Java-/Node-Dienst dazu | ~1,17 | ~35,5 |
CPX42 | 16 GB / 8 | WP + mehrere App-Stacks + Observability (Grafana/Loki) auf einer Box | ~2,28 | ~69,5 |
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.
/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.
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).
# 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
Firewall zuerst, Server danach – die Reihenfolge ist Absicht:
| Schalter | steht bei | bewirkt |
|---|---|---|
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. |
#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";
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.
| Zeile | Zweck | bewirkt |
|---|---|---|
#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 /swapfile … swapon |
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. |
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.
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
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: {}
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 }
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
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).
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).
DB_NAME=wp_blog
DB_USER=wp_blog
DB_PASSWORD=# openssl rand -base64 24
WP_HOST=blog.seb4u.com
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
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:
[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
# 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
Ein Umzug in drei Teilen – Datenbank, Dateien, URLs. Der dritte Teil ist der, den man beim ersten Mal vergisst:
| Schalter | gehört zu | bewirkt |
|---|---|---|
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
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
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 externendb_netund setzenWORDPRESS_DB_HOST: mariadb. - Jede App bekommt eigene DB + eigenen User, der nur auf seine DB
GRANThat – 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.
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.
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
# 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.
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 }
| Dienst | Wofür | Falle |
|---|---|---|
| Nextcloud | Dateien, Kalender, Kontakte | braucht DB (MariaDB/Postgres) + Redis, overwriteprotocol=https + trusted_domains setzen |
| Gitea / Forgejo | eigenes Git | SSH-Port des Containers separat (z. B. 2222) – braucht eine eigene Firewall-Regel |
| Uptime Kuma | Monitoring der anderen Dienste | klein, keine DB, sollte von außen prüfen, nicht nur localhost |
| Vaultwarden | Passwörter | Blast Radius – eigener Server erwägen |
| n8n / Grafana / Metabase | Automatisierung / Dashboards | RAM-hungrig (Node/JVM) – Servergröße neu rechnen |
| statische Seite | Landing, Doku | nginx: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.
#!/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"
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 -
Kurz, aber dicht – fast jede Zeile enthält einen Schalter, ohne den das Backup still unbrauchbar wäre:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
#!/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 stop … start |
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. |
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
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
Beim Zurückspielen zählt vor allem die Reihenfolge – jeder Schritt setzt den vorigen voraus:
| Schalter | Schritt | bewirkt |
|---|---|---|
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 rm … docker 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):
.\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
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, nichtlatest) – dann bringtpullnur 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.jsongesetzt (10 MB × 3 pro Container).journaldzusätzlich begrenzen:journalctl --vacuum-time=14dbzw.SystemMaxUse=500M. df -hunddocker system dfins 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.
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:
| Signal | Warum 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. Bastelkram | Trennung 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 Dienste | zweite Box oder Wechsel auf k3s (dann wird Scheduling / Ressourcenlimit zum Werkzeug statt zum Ratespiel). |
| Getrennte Mandanten / Kunden | je 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.
| Variante | Server | ~€/Tag | ~€/Mon |
|---|---|---|---|
| 3 einzelne Anleitungen | CPX22 Container-Host + CPX22 WordPress + CPX22 Vaultwarden | ~1,92 | ~58,5 |
| 1 konsolidiert | 1× CPX32 (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(oderpodman-compose) versteht dieselbencompose.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 echtesystemd-Units (systemctl restart wordpress). Ein.networkfürsproxy-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=80setzen oder Traefik als root/rootful). - Der container-host-Guide hat den Podman/Docker-Umschalter im Detail.