Ghost-Instanz mit Docker Compose migrieren – ohne Datenverlust

Ghost-Instanz mit Docker Compose migrieren – ohne Datenverlust
Photo by Mohammad Rahmani / Unsplash

Ein vollständiger Leitfaden zum Umzug deiner Ghost-Blog-Installation auf einen neuen Server mit minimaler Downtime


Irgendwann kommt der Moment: Der alte Server ist zu langsam, zu teuer, oder du willst einfach zu einem besseren Anbieter wechseln. Wenn dein Ghost-Blog mit Docker Compose betrieben wird, ist die Migration grundsätzlich gut beherrschbar – aber es gibt einige Fallstricke, die schnell zu Datenverlust oder längerer Downtime führen können.

Dieser Artikel zeigt dir den gesamten Prozess Schritt für Schritt: von der Vorbereitung über den Datenbankexport bis zur NGINX-Konfiguration auf dem neuen Server, in der richtigen Reihenfolge, mit echten Beispielen.


Voraussetzungen & Überblick

Wir gehen von folgender Ausgangslage aus:

  • Ghost läuft mit Docker Compose (Ghost + MySQL/MariaDB + ggf. NGINX als Reverse Proxy)
  • Als Datenbank wird MySQL 8 verwendet (Ghost unterstützt kein SQLite in Produktion empfohlen)
  • Der alte Server läuft weiter, bis der neue vollständig bereit ist
  • Auf beiden Servern ist Docker, Docker Compose und NGINX installiert

Die Migration erfolgt in dieser groben Reihenfolge:

  1. Neuen Server vorbereiten
  2. Ghost auf dem neuen Server aufsetzen (noch ohne Traffic)
  3. Datenbank exportieren und importieren
  4. Content-Files (Bilder, Themes, etc.) übertragen
  5. Ghost-Konfiguration anpassen
  6. NGINX auf dem neuen Server konfigurieren
  7. DNS umschalten (minimale Downtime)
  8. Alten Server abschalten

Schritt 1: Den alten Server verstehen – was liegt wo?

Bevor du irgendetwas kopierst, verschaffe dir einen Überblick. Eine typische Docker-Compose-Struktur für Ghost sieht so aus:

/opt/ghost/
├── docker-compose.yml
├── config.production.json     # optional, falls außerhalb des Containers
├── ghost-data/                # Volume: Ghost Content
│   ├── themes/
│   ├── images/
│   ├── files/
│   ├── media/
│   └── ...
└── mysql-data/                # Volume: MySQL Daten

Eine typische docker-compose.yml:

version: '3.8'

services:
  ghost:
    image: ghost:5-alpine
    container_name: ghost
    restart: always
    ports:
      - "2368:2368"
    environment:
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: supersecretpassword
      database__connection__database: ghost
      url: https://meinblog.de
      NODE_ENV: production
    volumes:
      - ghost-data:/var/lib/ghost/content
    depends_on:
      - db

  db:
    image: mysql:8.0
    container_name: ghost-db
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: supersecretpassword
    volumes:
      - mysql-data:/var/lib/mysql

volumes:
  ghost-data:
  mysql-data:
Wichtig: Notiere dir alle Umgebungsvariablen – insbesondere die Datenbank-Credentials und die url. Du brauchst sie auf dem neuen Server.

Schritt 2: Neuen Server vorbereiten

Installiere auf dem neuen Server Docker und Docker Compose:

# Docker installieren (Ubuntu/Debian)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

# Docker Compose Plugin prüfen
docker compose version

Erstelle das Verzeichnis für Ghost:

sudo mkdir -p /opt/ghost
cd /opt/ghost

Kopiere deine docker-compose.yml vom alten Server (per scp oder manuell):

# Vom alten Server aus ausführen:
scp /opt/ghost/docker-compose.yml user@neuer-server:/opt/ghost/docker-compose.yml

Starte Ghost auf dem neuen Server NOCH NICHT vollständig – zunächst nur die Datenbank, damit MySQL initialisiert wird:

# Nur die Datenbank starten
docker compose up -d db

# Kurz warten, bis MySQL bereit ist
sleep 15
docker compose logs db | tail -20

Schritt 3: Datenbank exportieren (alter Server)

Das ist der kritischste Schritt. Achte darauf, während des Exports keine Änderungen am Blog vorzunehmen (keine neuen Posts, keine Kommentare).

Option A: mysqldump direkt aus dem Container

# Auf dem alten Server:
docker exec ghost-db mysqldump \
  -u ghost \
  -psupersecretpassword \
  --single-transaction \
  --routines \
  --triggers \
  ghost > /tmp/ghost_backup.sql

# Größe prüfen
ls -lh /tmp/ghost_backup.sql
--single-transaction ist essenziell bei InnoDB – es erstellt einen konsistenten Snapshot ohne die Datenbank zu sperren.

Option B: Vollständiges Backup inkl. aller Datenbanken

docker exec ghost-db mysqldump \
  -u root \
  -prootpassword \
  --all-databases \
  --single-transaction \
  > /tmp/ghost_full_backup.sql

SQL-Dump auf den neuen Server übertragen:

scp /tmp/ghost_backup.sql user@neuer-server:/tmp/ghost_backup.sql

Schritt 4: Datenbank importieren (neuer Server)

# Auf dem neuen Server:
# Dump in den laufenden MySQL-Container kopieren
docker cp /tmp/ghost_backup.sql ghost-db:/tmp/ghost_backup.sql

# Import ausführen
docker exec -i ghost-db mysql \
  -u ghost \
  -psupersecretpassword \
  ghost < /tmp/ghost_backup.sql

# Alternativ: direkt über STDIN (ohne cp)
docker exec -i ghost-db mysql \
  -u ghost \
  -psupersecretpassword \
  ghost < /tmp/ghost_backup.sql

Import verifizieren:

docker exec -it ghost-db mysql \
  -u ghost \
  -psupersecretpassword \
  -e "USE ghost; SHOW TABLES; SELECT COUNT(*) FROM posts;" ghost

Die Ausgabe sollte alle Ghost-Tabellen und deine Posts zeigen.


Schritt 5: Content-Volume übertragen (Bilder, Themes, Files)

Der Ghost-Content liegt im Docker Volume ghost-data. Wir müssen den gesamten Inhalt von /var/lib/ghost/content übertragen.

Methode 1: tar-Archiv über SSH (empfohlen)

# Auf dem alten Server:
# Content aus dem Container in ein Archiv packen
docker run --rm \
  -v ghost_ghost-data:/source \
  -v /tmp:/backup \
  alpine \
  tar czf /backup/ghost_content.tar.gz -C /source .

# Archiv auf neuen Server übertragen
scp /tmp/ghost_content.tar.gz user@neuer-server:/tmp/ghost_content.tar.gz
Hinweis: Der Volume-Name hängt vom Verzeichnis ab, in dem deine docker-compose.yml liegt. Mit docker volume ls kannst du den exakten Namen ermitteln.
docker volume ls | grep ghost
# Beispiel-Output:
# local     ghost_ghost-data
# local     ghost_mysql-data

Auf dem neuen Server entpacken:

# Volume muss existieren (wird beim ersten `docker compose up` erstellt)
# Falls noch nicht vorhanden:
docker compose up -d db  # Ghost noch nicht starten!

# Warten bis das Volume angelegt wurde, dann Inhalt einspielen
docker run --rm \
  -v ghost_ghost-data:/target \
  -v /tmp:/backup \
  alpine \
  sh -c "cd /target && tar xzf /backup/ghost_content.tar.gz"

# Inhalt prüfen
docker run --rm \
  -v ghost_ghost-data:/data \
  alpine \
  ls -la /data/

Methode 2: rsync direkt (falls Volumes im Filesystem zugänglich)

Wenn die Volumes unter /var/lib/docker/volumes/ liegen und du Root-Zugriff hast:

# Auf dem alten Server (als root):
rsync -avz --progress \
  /var/lib/docker/volumes/ghost_ghost-data/_data/ \
  user@neuer-server:/tmp/ghost-content-sync/

# Auf dem neuen Server in Volume einspielen:
docker run --rm \
  -v ghost_ghost-data:/target \
  -v /tmp/ghost-content-sync:/source \
  alpine \
  cp -a /source/. /target/

Schritt 6: Ghost-Konfiguration auf dem neuen Server anpassen

Passe die docker-compose.yml auf dem neuen Server an. Die URL muss bereits auf den neuen Server zeigen (oder noch die alte Domain, wenn du einen Testlauf machen willst):

environment:
  url: https://meinblog.de          # Bleibt gleich
  database__connection__host: db    # Bleibt gleich (interner Service-Name)
  # Alle anderen Werte wie gehabt

Falls du einen Testlauf über die IP-Adresse des neuen Servers machen willst:

environment:
  url: http://123.45.67.89:2368    # Temporär für den Test
Fallstrick: Ghost speichert die URL in der Datenbank (in der Tabelle settings). Wenn du die URL später änderst, musst du Ghost neu starten oder den Wert in der DB anpassen:

Schritt 7: NGINX auf dem neuen Server konfigurieren

Grundlegende NGINX-Konfiguration für Ghost

Erstelle die Konfiguration (z.B. unter /etc/nginx/sites-available/meinblog.de):

server {
    listen 80;
    server_name meinblog.de www.meinblog.de;

    # Weiterleitung zu HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name meinblog.de www.meinblog.de;

    # SSL-Zertifikat (Let's Encrypt)
    ssl_certificate     /etc/letsencrypt/live/meinblog.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/meinblog.de/privkey.pem;

    # SSL-Härtung
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # Sicherheits-Header
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Client-Upload-Größe (für Bilder/Dateien)
    client_max_body_size 50m;

    # Proxy zu Ghost
    location / {
        proxy_pass http://127.0.0.1:2368;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Wichtig für Ghost's URL-Erkennung:
        proxy_set_header X-Forwarded-Host $host;

        # Timeouts
        proxy_connect_timeout       60s;
        proxy_send_timeout          60s;
        proxy_read_timeout          60s;

        # WebSocket-Support (Ghost Admin nutzt das)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # Statische Assets direkt aus dem Volume ausliefern (optional, performanter)
    location ~* ^/content/images/ {
        root /var/lib/docker/volumes/ghost_ghost-data/_data;
        rewrite ^/content/images/(.*) /images/$1 break;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

Konfiguration aktivieren und testen:

sudo ln -s /etc/nginx/sites-available/meinblog.de /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

SSL-Zertifikat mit Certbot holen (vor dem DNS-Wechsel)

Du kannst das Zertifikat bereits vor dem DNS-Umzug mit dem DNS-Challenge-Verfahren ausstellen:

sudo certbot certonly \
  --manual \
  --preferred-challenges dns \
  -d meinblog.de \
  -d www.meinblog.de

Oder nach dem DNS-Wechsel einfach:

sudo certbot --nginx -d meinblog.de -d www.meinblog.de

Schritt 8: Ghost auf dem neuen Server starten und testen

cd /opt/ghost
docker compose up -d

# Logs beobachten
docker compose logs -f ghost

Typische Erfolgsmeldung in den Logs:

ghost    | [2024-01-15 10:23:45] INFO Ghost is running in production...
ghost    | [2024-01-15 10:23:45] INFO Your blog is available on https://meinblog.de

Funktionstest über die IP-Adresse

Teste den Blog direkt über die IP (falls url noch auf die Domain zeigt, werden interne Links zur Domain zeigen – das ist normal):

curl -I http://123.45.67.89:2368
# HTTP/1.1 200 OK erwartet

Oder öffne http://123.45.67.89:2368 im Browser und prüfe:

  • Sind alle Posts vorhanden?
  • Werden Bilder korrekt angezeigt?
  • Funktioniert das Admin-Panel unter /ghost?

Schritt 9: Finales Backup kurz vor dem DNS-Wechsel

Direkt vor dem DNS-Wechsel machst du ein letztes, aktuelles Backup – damit keine Daten zwischen dem letzten regulären Backup und dem Umzug verloren gehen:

# Auf dem alten Server – finale Sicherung
docker exec ghost-db mysqldump \
  -u ghost \
  -psupersecretpassword \
  --single-transaction \
  ghost > /tmp/ghost_final_backup_$(date +%Y%m%d_%H%M%S).sql

# Nur neue/geänderte Files synchronisieren (Delta-Sync)
docker run --rm \
  -v ghost_ghost-data:/source \
  -v /tmp:/backup \
  alpine \
  tar czf /backup/ghost_content_final.tar.gz -C /source .

scp /tmp/ghost_final_backup_*.sql user@neuer-server:/tmp/
scp /tmp/ghost_content_final.tar.gz user@neuer-server:/tmp/

Dieses finale Backup auf dem neuen Server einspielen (wie in Schritt 4 und 5 beschrieben).


Schritt 10: DNS umschalten – minimale Downtime

Das ist der Moment, in dem die Downtime entsteht. Mit der richtigen Vorbereitung ist sie minimal.

Vorbereitung: TTL vorher reduzieren

24–48 Stunden vor dem Umzug die TTL deines DNS-Eintrags auf einen niedrigen Wert setzen:

meinblog.de.    300   IN  A  <alte-ip>    # TTL: 300 Sekunden (5 Minuten)

So propagiert der neue DNS-Eintrag nach dem Wechsel viel schneller.

DNS-Eintrag aktualisieren

Im DNS-Panel (Hetzner, Cloudflare, etc.) den A-Record auf die neue Server-IP setzen:

meinblog.de.    300   IN  A  <neue-ip>
www.meinblog.de. 300  IN  A  <neue-ip>

Propagation prüfen:

# Warte und prüfe, bis die neue IP aufgelöst wird
watch -n 5 "dig +short meinblog.de"

# Oder von verschiedenen Standorten aus:
curl -s https://dns.google/resolve?name=meinblog.de&type=A | python3 -m json.tool

Schritt 11: Alten Server herunterfahren

Sobald der DNS vollständig auf den neuen Server zeigt und alles korrekt funktioniert:

# Auf dem alten Server Ghost stoppen
cd /opt/ghost
docker compose down

# Optional: Backup der alten Daten vor dem Löschen
tar czf /tmp/ghost_old_server_final.tar.gz /opt/ghost/

Lass den alten Server noch 1–2 Tage laufen (ohne Ghost), bevor du ihn abschaltest – für den Fall, dass noch etwas schiefläuft.


Bekannte Fallstricke und wie du sie vermeidest

❌ Fallstrick 1: Falsche Volume-Namen

Docker-Compose-Volume-Namen enthalten den Verzeichnisnamen als Präfix. Wenn du das Projekt in /opt/ghost hast, heißt das Volume ghost_ghost-data, nicht ghost-data.

# Immer zuerst prüfen:
docker volume ls

❌ Fallstrick 2: Ghost startet, aber Bilder fehlen

Ursache: Die Content-Files wurden nicht vollständig übertragen, oder die Berechtigungen stimmen nicht.

# Berechtigungen im Volume prüfen und korrigieren
docker run --rm \
  -v ghost_ghost-data:/data \
  alpine \
  chown -R 1000:1000 /data

Ghost läuft im Container als User node (UID 1000).

❌ Fallstrick 3: Ghost-Admin zeigt "Password Reset" oder Login funktioniert nicht

Ursache: Die URL in der Ghost-Datenbank stimmt nicht mit der tatsächlichen URL überein. Ghost sendet Reset-Links und Admin-Tokens auf Basis dieser URL.

docker exec -it ghost-db mysql -u ghost -psupersecretpassword ghost -e \
  "SELECT value FROM settings WHERE key='url';"

# Falls falsch:
docker exec -it ghost-db mysql -u ghost -psupersecretpassword ghost -e \
  "UPDATE settings SET value='https://meinblog.de' WHERE key='url';"

# Ghost neustarten
docker compose restart ghost

❌ Fallstrick 4: NGINX gibt 502 Bad Gateway zurück

Ghost ist noch nicht bereit, oder der Port stimmt nicht.

# Ghost-Status prüfen
docker compose ps
docker compose logs ghost | tail -30

# Port-Mapping prüfen
docker port ghost

Häufige Ursache: Ghost braucht beim ersten Start mit einer neuen Datenbank etwas länger. Warte 30–60 Sekunden und lade neu.

❌ Fallstrick 5: MySQL 8 Auth-Plugin-Probleme

MySQL 8 nutzt standardmäßig caching_sha2_password. Falls Ghost Verbindungsprobleme hat:

docker exec -it ghost-db mysql -u root -prootpassword -e \
  "ALTER USER 'ghost'@'%' IDENTIFIED WITH mysql_native_password BY 'supersecretpassword';"

❌ Fallstrick 6: Zu große MySQL-Dumps / Timeout beim Import

Bei großen Blogs kann der Import lange dauern. Erhöhe die MySQL-Timeouts:

docker exec -i ghost-db mysql \
  -u ghost \
  -psupersecretpassword \
  --connect-timeout=300 \
  --max-allowed-packet=256M \
  ghost < /tmp/ghost_backup.sql

❌ Fallstrick 7: Ghost Emails funktionieren nicht mehr

Falls du Mailgun, SendGrid oder SMTP konfiguriert hast, prüfe die Mailkonfiguration in der docker-compose.yml. Diese ist nicht in der Datenbank, sondern in den Umgebungsvariablen:

environment:
  mail__transport: SMTP
  mail__options__host: smtp.mailgun.org
  mail__options__port: 587
  mail__options__auth__user: postmaster@meinblog.de
  mail__options__auth__pass: dein-smtp-passwort
  mail__from: noreply@meinblog.de

❌ Fallstrick 8: Members/Subscriptions mit Stripe

Falls du Ghost Members mit Stripe-Integration nutzt, musst du sicherstellen, dass die Stripe-Webhooks auf die neue Domain zeigen. Im Stripe-Dashboard unter Developers → Webhooks die Endpoint-URL aktualisieren.


Komplettes Migrations-Cheatsheet

# === ALTER SERVER ===

# 1. Datenbank-Backup
docker exec ghost-db mysqldump -u ghost -psupersecretpassword \
  --single-transaction ghost > /tmp/ghost_backup.sql

# 2. Content-Backup
docker run --rm -v ghost_ghost-data:/source -v /tmp:/backup alpine \
  tar czf /backup/ghost_content.tar.gz -C /source .

# 3. Konfiguration sichern
cp /opt/ghost/docker-compose.yml /tmp/docker-compose.yml.bak

# 4. Auf neuen Server übertragen
scp /tmp/ghost_backup.sql user@NEUE_IP:/tmp/
scp /tmp/ghost_content.tar.gz user@NEUE_IP:/tmp/
scp /opt/ghost/docker-compose.yml user@NEUE_IP:/opt/ghost/


# === NEUER SERVER ===

# 5. MySQL starten und warten
cd /opt/ghost && docker compose up -d db && sleep 20

# 6. Datenbank importieren
docker exec -i ghost-db mysql -u ghost -psupersecretpassword ghost \
  < /tmp/ghost_backup.sql

# 7. Content einspielen
docker run --rm -v ghost_ghost-data:/target -v /tmp:/backup alpine \
  sh -c "cd /target && tar xzf /backup/ghost_content.tar.gz"

# 8. NGINX konfigurieren und SSL holen
sudo certbot --nginx -d meinblog.de

# 9. Ghost starten
docker compose up -d

# 10. Testen, dann DNS umschalten

Fazit

Eine Ghost-Migration mit Docker Compose ist gut durchführbar, wenn man die Reihenfolge einhält: erst Datenbank sichern, dann Content übertragen, dann den neuen Server vollständig testen – und erst ganz am Ende den DNS umschalten. Mit einer vorher reduzierten TTL ist die tatsächliche Downtime auf wenige Minuten begrenzt.

Die häufigsten Probleme entstehen durch falsche Volume-Namen, Berechtigungen oder die URL-Einstellung in der Ghost-Datenbank. Mit den hier gezeigten Prüfschritten findest du diese Probleme schnell.

Hast du Fragen zu einem spezifischen Schritt oder einer anderen Datenbankversion? Schreib es in die Kommentare.


Zuletzt aktualisiert: 2026 – getestet mit Ghost 5.x, MySQL 8.0, Docker Compose v2