Dieser Beitrag ist ein erprobtes Runbook: ein WordPress von Strato auf einen Hostinger-VPS ziehen, auf dem ein Docker-Compose-Stack (wordpress:latest + mysql:8) läuft und Traefik als Reverse Proxy im Host-Mode das TLS macht. Files (Verzeichnis) und DB-Dump liegen bereits auf dem VPS. Es sind genau die Schritte – inklusive der vier, fünf Stolperfallen, an denen man garantiert hängenbleibt.
WordPress wurde zuvor via Hostinger VPS Catalog in einem Docker Container installiert.
Die Datenbank und das Verzeichnis bei Strato wurden zuvor via FileZilla heruntergeladen und via phpMyAdmin als .sql gesichert.
Die Beispielwerte (
wordpress-ibr4-*,jryy_,abow.info, IP72.61.92.170) stammen aus dem realen Umzug. Passe sie an deinen Stack an.
Ziel
WordPress läuft im vorhandenen Docker-Stack, ist über https://abow.info öffentlich erreichbar, Zertifikat von Let’s Encrypt – ohne Datenverlust.
Mentales Modell (vorab verstehen, spart 2 Stunden)
- Named Volume: Der WP-Container mountet
…/<volume>/_data→/var/www/html. Deine Files müssen in_dataliegen, nicht daneben. Und:_dataniemals löschen – ein Named Volume braucht diesen Ordner, sonst startet der Container nicht. - Die
wp-config.phperzeugt das offizielle Image selbst – aber nur, wenn keine existiert, und zwar aus denWORDPRESS_*-ENV-Variablen. Eine mitgebrachte Strato-wp-config.phpwird nicht überschrieben. - DB-Verbindung geht über den Service-Namen (
db), nielocalhost/rdbms.strato.de. - Traefik im Host-Mode routet nicht über das Docker-Netz, sondern über einen festen veröffentlichten Port des Containers.
Schritt 0: Stack inspizieren
ssh root@DEINE_VPS_IP
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"
Du suchst den WP- und den DB-Container (hier wordpress-ibr4-wordpress-1 / wordpress-ibr4-db-1, DB-Image mysql:8) und den Reverse Proxy (traefik-traefik-1). Projektpfad und Volume finden:
docker inspect wordpress-ibr4-wordpress-1 \
--format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
# -> z.B. /docker/wordpress-ibr4
docker inspect wordpress-ibr4-wordpress-1 \
--format '{{ range .Mounts }}{{ .Name }} | {{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}'
# -> wordpress-ibr4_wordpress | …/_data -> /var/www/html
DB-Zugangsdaten + Prefix aus den Containern (nur ansehen):
docker exec wordpress-ibr4-wordpress-1 env | grep -i WORDPRESS_DB
docker exec wordpress-ibr4-db-1 env | grep -iE 'MYSQL_DATABASE|MYSQL_USER'
Schritt 1: Files an die richtige Stelle (_data)
Liegen deine Strato-Files in einem Ordner neben _data (z. B. _app), sieht WordPress sie nicht. Ist _data nur die nackte Standard-Installation, deine echte Seite aber in _app, machst du _app zum Live-Verzeichnis:
cd /var/lib/docker/volumes/wordpress-ibr4_wordpress
# Falls _data noch die Default-Files enthaelt: sichern statt loeschen
mv _app _data
# Core vollstaendig? (muessen existieren)
ls _data/wp-admin _data/wp-includes _data/wp-load.php
Falle:
_datazu löschen killt den Mountpunkt →Error response from daemon: open …/_data: no such file or directory. Immer umbenennen, nie löschen.
Schritt 2: Alte Strato-wp-config raus, ENV-Prefix korrekt setzen
Die mitgebrachte wp-config.php zeigt noch auf Strato:
grep -E "table_prefix|DB_HOST|DB_NAME|DB_USER" _data/wp-config.php
# define('DB_HOST','rdbms.strato.de'); <- muss weg
# $table_prefix = 'jryy_'; <- Prefix merken!
Prefix merken (hier jryy_) und die alte Config wegsichern, damit das Image neu generiert:
mv _data/wp-config.php _data/wp-config.php.strato-bak
Jetzt den Table-Prefix in die Compose – und zwar im environment:-Block des wordpress-Service (nicht beim db-Service!). Häufigster Fehler: Zeile landet bei den MYSQL_*-Variablen und greift nie.
services:
wordpress:
environment:
- WORDPRESS_DB_HOST=db
- WORDPRESS_DB_NAME=wordpress
- WORDPRESS_DB_USER=wordpress
- WORDPRESS_DB_PASSWORD=${MYSQL_PASSWORD}
- WORDPRESS_TABLE_PREFIX=jryy_ # <- HIER, beim wordpress-Service
Schritt 3: Container neu erzeugen und Prefix effektiv prüfen
docker compose up -d allein startet einen laufenden Container nicht neu – dann läuft der Entrypoint nicht und die wp-config.php wird nicht geschrieben. Daher --force-recreate:
cd /docker/wordpress-ibr4
docker compose up -d --force-recreate wordpress
Jetzt nicht nur grep (das offizielle Image schreibt getenv_docker('…','wp_') mit Fallback). Den aufgelösten Wert prüfen:
docker exec wordpress-ibr4-wordpress-1 \
php -r 'require "/var/www/html/wp-config.php"; echo DB_HOST.PHP_EOL.DB_NAME.PHP_EOL.$table_prefix.PHP_EOL;'
Zielausgabe:
db
wordpress
jryy_
Kommt
wp_stattjryy_: Die ENV-Zeile steht im falschen Service-Block oder die altewp-config.phpexistiert noch. Beides fixen,--force-recreatewiederholen. Effektiv-Check mitdocker exec … env | grep WORDPRESS_TABLE_PREFIXzeigt, ob die Variable im Container überhaupt ankommt.
Schritt 4: Datenbank importieren
docker exec -i … < dump.sql liest die Datei vom Host und pipet sie in den Container – der Pfad im Container ist egal, nur der Host-Pfad muss stimmen. Passwort kommt aus der Container-ENV, du tippst nichts:
docker exec -i wordpress-ibr4-db-1 \
sh -c 'exec mysql -uwordpress -p"$MYSQL_PASSWORD" wordpress' \
< /var/lib/docker/volumes/wordpress-ibr4_wordpress/dbs12659889.sql
Falle
ERROR 1050 … Table already exists: In der DB liegen schon Tabellen (Reste der Default-Installation oder ein abgebrochener Import). Schema einmal komplett leeren, dann neu importieren:docker exec wordpress-ibr4-db-1 sh -c ' exec mysql -uwordpress -p"$MYSQL_PASSWORD" -N -e " SET FOREIGN_KEY_CHECKS=0; SET GROUP_CONCAT_MAX_LEN=1000000; SELECT IFNULL(CONCAT(\"DROP TABLE IF EXISTS \", GROUP_CONCAT(\"\`\",table_name,\"\`\"), \";\"), \"SELECT 1;\") FROM information_schema.tables WHERE table_schema=\"wordpress\"; " wordpress' \ | docker exec -i wordpress-ibr4-db-1 sh -c 'exec mysql -uwordpress -p"$MYSQL_PASSWORD" wordpress'Danach
SHOW TABLES;muss leer sein → Import wiederholen.
Rechte setzen und verifizieren (Prefix beachten – hier jryy_):
docker exec wordpress-ibr4-wordpress-1 chown -R www-data:www-data /var/www/html
docker exec wordpress-ibr4-db-1 \
sh -c 'exec mysql -uwordpress -p"$MYSQL_PASSWORD" wordpress \
-e "SELECT option_name,option_value FROM jryy_options WHERE option_name IN (\"siteurl\",\"home\");"'
docker exec wordpress-ibr4-wordpress-1 \
php -r 'require "/var/www/html/wp-load.php"; echo "WP ".$GLOBALS["wp_version"]." | ".get_option("blogname").PHP_EOL;'
Erwartung: deine siteurl/home und WP-Version + Blogname.
Schritt 5: URLs (nur bei Domain-/Protokollwechsel)
Stehen siteurl/home bereits korrekt (z. B. https://abow.info), ist nichts zu tun – diesen Schritt überspringen. Andernfalls serialisierungssicher mit WP-CLI ersetzen, nie per rohem SQL:
docker compose exec wordpress bash -c '
curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php wp-cli.phar search-replace "http://alt.tld" "https://abow.info" --all-tables --allow-root --dry-run'
Sieht der Probelauf gut aus → --dry-run weglassen.
Schritt 6: HTTPS hinter Traefik
Traefik terminiert TLS und spricht intern per HTTP mit WordPress. Ohne Hinweis baut WordPress Redirect-Loops / Mixed-Content. Per ENV in den wordpress-Service (die $$ sind Absicht – Compose würde einfaches $ als Variable interpretieren):
- WORDPRESS_CONFIG_EXTRA=if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $$_SERVER['HTTPS'] = 'on'; }
Schritt 7: Traefik-Routing im Host-Mode
Prüfen, wie Traefik tickt:
docker inspect traefik-traefik-1 --format '{{ json .Config.Cmd }}'
# -> providers.docker=true, entrypoints web:80 / websecure:443,
# HTTP->HTTPS-Redirect, certresolver "letsencrypt" (HTTP-Challenge)
docker inspect traefik-traefik-1 \
--format '{{ range $n,$v := .NetworkSettings.Networks }}{{ $n }} {{ end }}'
# -> host (= Host-Mode!)
Host-Mode-Konsequenzen (das ist die größte Falle):
docker network connectist nicht möglich (Container im Host-Namespace).- Traefik erreicht den WP-Container nur über einen festen, veröffentlichten Port auf
127.0.0.1. - Im Label statt
loadbalancer.server.port→loadbalancer.server.urlmit der festen URL.
Daher in der Compose den WP-Port fix mappen (nicht dynamisch "80", das vergibt Docker zufällig):
ports:
- "127.0.0.1:8081:80"
Und die Labels auf Service-Ebene (gleiche Einrückung wie environment:/volumes:, nur einmal labels: pro Service!):
labels:
- "traefik.enable=true"
- "traefik.http.routers.abow.rule=Host(`abow.info`)"
- "traefik.http.routers.abow.entrypoints=websecure"
- "traefik.http.routers.abow.tls=true"
- "traefik.http.routers.abow.tls.certresolver=letsencrypt"
- "traefik.http.services.abow.loadbalancer.server.url=http://127.0.0.1:8081"
YAML-Fallen:
labels:darf nicht untervolumes:verschachtelt sein und nicht doppelt vorkommen (mapping key "labels" already defined). Nach jeder Änderung validieren:docker compose config >/dev/null && echo "YAML OK" || echo "YAML kaputt"
Anwenden und intern testen (ohne auf DNS/Zertifikat zu warten):
docker compose up -d wordpress
curl -s -I http://127.0.0.1:8081/ | head -1 # WordPress direkt: 200/301
curl -s -I -H "Host: abow.info" http://127.0.0.1/ | head -1 # ueber Traefik
HTTP/1.1 308 Permanent Redirect beim zweiten Befehl = Traefik kennt den abow-Router und leitet auf HTTPS um. Routing steht.
Schritt 8: DNS umstellen → Zertifikat kommt automatisch
Das Let’s-Encrypt-Zertifikat (HTTP-Challenge) zieht erst, wenn die Domain öffentlich auf den VPS zeigt. Beim DNS-Anbieter für abow.info:
| Typ | Name | Wert |
|---|---|---|
| A | @ | 72.61.92.170 (VPS-IPv4, via curl -s -4 ifconfig.me) |
| AAAA | @ | deine VPS-IPv6 (oder AAAA entfernen) |
Vorher testen ohne DNS-Wartezeit: per Windows-
hosts-Dateiabow.info→ VPS-IP (PowerShell-Skript unten). Browser-Zertifikatswarnung ist dabei normal, bis das echte Zert gezogen ist.
Nach Propagation prüfen:
dig +short abow.info # -> 72.61.92.170
curl -sI https://abow.info | head -3 # -> HTTP/2 200
echo | openssl s_client -connect abow.info:443 -servername abow.info 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates # Issuer: Let's Encrypt
HTTP/2 200 ohne TLS-Fehler = live mit gültigem Zertifikat.
Schritt 9: Nacharbeiten & Aufräumen
# In WordPress: Einstellungen -> Permalinks -> ohne Aenderung speichern (Rewrites/.htaccess)
# Backups/Reste entfernen
rm -rf /var/lib/docker/volumes/wordpress-ibr4_wordpress/_data_OLD
rm -f /var/lib/docker/volumes/wordpress-ibr4_wordpress/_data/wp-config.php.*-bak*
rm -f /docker/wordpress-ibr4/docker-compose.yml.bak*
- DB-Passwort rotieren, falls es irgendwo im Klartext aufgetaucht ist.
- Strato-Dump nach erfolgreicher Kontrolle sichern/löschen.
PowerShell: Vorab-Test per hosts-Datei
<#
.NOTES
Name: PS_Hosts_Preview_VPS
Author: Andreas Bowitz
Version: 0.2
LastUpdated: 2026-Jun-03
.SYNOPSIS
Setzt temporär einen hosts-Eintrag, um eine Domain vor der DNS-Umstellung
auf die neue VPS-IP zeigen zu lassen (lokaler Test vom Windows-Client).
#>
$VpsIP = "72.61.92.170"
$Domain = "abow.info"
$HostsFile = "$env:WINDIR\System32\drivers\etc\hosts"
$Entry = "$VpsIP`t$Domain"
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning "Bitte PowerShell als Administrator starten."; return
}
if ((Get-Content $HostsFile) -match [Regex]::Escape($Domain)) {
Write-Host "Eintrag fuer '$Domain' existiert bereits." -ForegroundColor Yellow
} else {
Add-Content -Path $HostsFile -Value $Entry
Write-Host "Hinzugefuegt: $Entry" -ForegroundColor Green
}
ipconfig /flushdns | Out-Null
Write-Host "Test: https://$Domain (Zert-Warnung bis echtem DNS normal). Danach Zeile wieder entfernen." -ForegroundColor Cyan
Bash: Import-Helfer (auf dem VPS)
#!/usr/bin/env bash
# .NOTES
# Name: migrate_strato_to_docker
# Author: Andreas Bowitz
# Version: 0.2
# LastUpdated: 2026-Jun-03
# Zweck: DB-Dump in den db-Container importieren + Rechte setzen.
# Im Compose-Projektverzeichnis ausfuehren.
set -euo pipefail
DB_CONTAINER="wordpress-ibr4-db-1"
WP_CONTAINER="wordpress-ibr4-wordpress-1"
DB_NAME="wordpress"
PREFIX="jryy_"
DUMP="${1:?Pfad zum SQL-Dump angeben}"
echo ">> Pruefe, ob Tabellen existieren ..."
COUNT=$(docker exec "$DB_CONTAINER" sh -c \
"exec mysql -uwordpress -p\"\$MYSQL_PASSWORD\" -N -e \
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=\"$DB_NAME\";'")
if [ "$COUNT" -gt 0 ]; then
echo "!! $COUNT Tabellen vorhanden. Erst leeren (manuell), dann erneut starten." ; exit 1
fi
echo ">> Importiere '$DUMP' ..."
docker exec -i "$DB_CONTAINER" \
sh -c 'exec mysql -uwordpress -p"$MYSQL_PASSWORD" '"$DB_NAME" < "$DUMP"
echo ">> Rechte setzen ..."
docker exec "$WP_CONTAINER" chown -R www-data:www-data /var/www/html
echo ">> Kontrolle ..."
docker exec "$DB_CONTAINER" sh -c \
"exec mysql -uwordpress -p\"\$MYSQL_PASSWORD\" $DB_NAME \
-e 'SELECT option_value FROM ${PREFIX}options WHERE option_name=\"siteurl\";'"
echo ">> Fertig."
Troubleshooting (gesammelte Stolperfallen)
| Symptom | Ursache | Lösung |
|---|---|---|
open …/_data: no such file or directory | _data (Mountpunkt des Named Volume) gelöscht | Volume-Ordner wiederherstellen, Files in _data legen – nie löschen |
php-Check zeigt wp_ statt jryy_ | WORDPRESS_TABLE_PREFIX im falschen Service-Block oder alte wp-config.php da | ENV beim wordpress-Service, alte Config umbenennen, --force-recreate |
wp-config.php wird nicht neu geschrieben | docker compose up -d recreatet laufenden Container nicht | docker compose up -d --force-recreate wordpress |
ERROR 1050 … already exists | Tabellen schon vorhanden | Schema leeren (DROP-Loop), dann neu importieren |
Error establishing a database connection | DB_HOST = localhost/rdbms.strato.de | auf Service-Namen db setzen |
mapping key "labels" already defined / mapping values not allowed | labels: doppelt oder falsch eingerückt | nur ein labels: auf Service-Ebene; docker compose config validieren |
| Traefik routet nicht / 404 | Host-Mode + dynamischer Port / server.port statt server.url | fixes 127.0.0.1:8081:80, Label loadbalancer.server.url=http://127.0.0.1:8081 |
| Kein Zertifikat | HTTP-Challenge scheitert, DNS zeigt noch auf Strato | A/AAAA auf VPS-IP, dann zieht Traefik automatisch |
| Redirect-Loop / Mixed Content | HTTPS hinter Proxy nicht erkannt | WORDPRESS_CONFIG_EXTRA mit HTTP_X_FORWARDED_PROTO |
| 404 auf Unterseiten | Permalinks/Rewrite | Permalinks ohne Änderung neu speichern |
Fazit: Auf einem Docker-VPS verlagert sich der Umzug komplett in die Shell. Die eigentliche Arbeit sind nicht die Standard-Schritte (Dump rein, Files rein), sondern die Eigenheiten der Umgebung: Named Volume = _data (nie löschen), WORDPRESS_TABLE_PREFIX beim richtigen Service plus --force-recreate, Effektiv-Prüfung mit php -r statt grep, und Traefik im Host-Mode mit festem Port und server.url. Wer diese vier Punkte kennt, zieht beim nächsten Mal in 20 Minuten um – ohne Hilfe.
