andi@abow: ~/hosting/ $ cat wordpress-umzug-von-strato-auf-einen-hostinger-vps-docker-traefik.md
>_abow

how do you do IT

Alle möglichen Handkniffe, die ich mir so zusammentrage im Berufsalltag

← cd ~/

WordPress // Umzug von Strato auf einen Hostinger-VPS (Docker + Traefik)

WordPress // Umzug von Strato auf einen Hostinger-VPS (Docker + Traefik)

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, IP 72.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 _data liegen, nicht daneben. Und: _data niemals löschen – ein Named Volume braucht diesen Ordner, sonst startet der Container nicht.
  • Die wp-config.php erzeugt das offizielle Image selbst – aber nur, wenn keine existiert, und zwar aus den WORDPRESS_*-ENV-Variablen. Eine mitgebrachte Strato-wp-config.php wird nicht überschrieben.
  • DB-Verbindung geht über den Service-Namen (db), nie localhost/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: _data zu 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_ statt jryy_: Die ENV-Zeile steht im falschen Service-Block oder die alte wp-config.php existiert noch. Beides fixen, --force-recreate wiederholen. Effektiv-Check mit docker exec … env | grep WORDPRESS_TABLE_PREFIX zeigt, 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 connect ist 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.portloadbalancer.server.url mit 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 unter volumes: 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:

TypNameWert
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-Datei abow.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)

SymptomUrsacheLösung
open …/_data: no such file or directory_data (Mountpunkt des Named Volume) gelöschtVolume-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 daENV beim wordpress-Service, alte Config umbenennen, --force-recreate
wp-config.php wird nicht neu geschriebendocker compose up -d recreatet laufenden Container nichtdocker compose up -d --force-recreate wordpress
ERROR 1050 … already existsTabellen schon vorhandenSchema leeren (DROP-Loop), dann neu importieren
Error establishing a database connectionDB_HOST = localhost/rdbms.strato.deauf Service-Namen db setzen
mapping key "labels" already defined / mapping values not allowedlabels: doppelt oder falsch eingerücktnur ein labels: auf Service-Ebene; docker compose config validieren
Traefik routet nicht / 404Host-Mode + dynamischer Port / server.port statt server.urlfixes 127.0.0.1:8081:80, Label loadbalancer.server.url=http://127.0.0.1:8081
Kein ZertifikatHTTP-Challenge scheitert, DNS zeigt noch auf StratoA/AAAA auf VPS-IP, dann zieht Traefik automatisch
Redirect-Loop / Mixed ContentHTTPS hinter Proxy nicht erkanntWORDPRESS_CONFIG_EXTRA mit HTTP_X_FORWARDED_PROTO
404 auf UnterseitenPermalinks/RewritePermalinks 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.

$ Kommentar hinterlassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert