Migrer WordPress vers Ghost en local avec Docker — Guide complet

Introduction

Ghost est un CMS moderne, léger et élégant, pensé exclusivement pour le blogging. Contrairement à WordPress qui est un couteau suisse parfois surdimensionné pour un simple blog technique, Ghost est rapide, sobre et bien plus agréable à utiliser au quotidien.

Dans cet article, nous allons voir comment :

  • Déployer Ghost en local via Docker
  • Importer tout le contenu de WordPress vers Ghost
  • Synchroniser automatiquement les nouveaux articles WordPress vers Ghost via un script Python et un cron hebdomadaire

Dans mon cas, Ghost me sert de backup local de khassam.fr, toujours disponible sur mon réseau même sans internet.


Prérequis

  • Un serveur Linux avec Docker et Docker Compose installés
  • Votre site WordPress avec le plugin WordPress to Ghost installé
  • Python 3 sur le serveur

Étape 1 — Exporter WordPress vers Ghost

Installez le plugin WordPress to Ghost depuis l’interface WordPress :

Extensions → Ajouter → rechercher « WordPress to Ghost » → Installer → Activer

Une fois activé, rendez-vous dans Outils → Export to Ghost et téléchargez le fichier .json généré. Ce fichier contient tous vos articles, pages et tags.


Étape 2 — Déployer Ghost avec Docker

Créez le répertoire de travail :

bash

mkdir ~/docker/ghost && cd ~/docker/ghost

Créez le fichier docker-compose.yml suivant :

yaml

services:
  ghost:
    image: ghost:latest
    container_name: ghost
    restart: unless-stopped
    ports:
      - "2368:2368"
    environment:
      NODE_ENV: production
      url: http://XX.XX.XX.XX:2368
      database__client: mysql
      database__connection__host: ghost-db
      database__connection__user: ghost
      database__connection__password: ghostpassword
      database__connection__database: ghost
    volumes:
      - ghost-data:/var/lib/ghost/content
    depends_on:
      - ghost-db

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

volumes:
  ghost-data:
  ghost-db-data:

Adaptez l’adresse IP et les mots de passe à votre configuration, puis lancez :

bash

docker compose up -d

Le premier démarrage prend 2 à 3 minutes — Docker télécharge les images et Ghost initialise la base de données MySQL.

Ghost est ensuite accessible sur : http://XX.XX.XX.XX:2368


Étape 3 — Créer le compte admin et importer le contenu

Rendez-vous sur http://xx.xx.xx.xx:2368/ghost pour créer votre compte administrateur (nom, email, mot de passe).

Une fois connecté, allez dans :

Settings → Labs → Import/Export → Universal import

Sélectionnez votre fichier .json exporté depuis WordPress et cliquez sur Import. Tous vos articles, pages et tags sont importés en quelques secondes.

Note : Les articles privés WordPress arrivent en statut Draft dans Ghost. Les articles protégés par mot de passe arrivent également en brouillon — Ghost ne gère pas les mots de passe par article de la même façon que WordPress.

Note sur les images : Les images ne sont pas incluses dans le JSON — elles restent hébergées sur votre site WordPress et continuent de s’afficher normalement tant que celui-ci reste en ligne.


Étape 4 — Désactiver les boutons Subscribe et Sign in

Ces boutons sont destinés à un système d’abonnement que vous n’utiliserez probablement pas pour un blog personnel. Pour les masquer :

Settings → Access → Subscription access → Nobody


Étape 5 — Synchronisation automatique WordPress → Ghost

Pour que Ghost reste à jour quand vous publiez de nouveaux articles sur WordPress, nous allons mettre en place un script Python qui interroge l’API WordPress et pousse les nouveaux articles vers Ghost.

Créer une clé API Ghost

Dans Settings → Integrations → Add custom integration, donnez un nom à l’intégration (ex: « WP Sync ») et notez la valeur de l’Admin API key.

Installer les dépendances Python

bash

pip3 install PyJWT requests --break-system-packages

Le script de synchronisation

Créez le fichier ~/docker/ghost/wp_to_ghost_sync.py avec le contenu suivant (adaptez les variables de configuration en haut du script) :

python

#!/usr/bin/env python3
"""
wp_to_ghost_sync.py
Synchronisation WordPress → Ghost
"""

import requests, json, jwt, datetime, os, sys, time

# ─── CONFIGURATION ────────────────────────────────────────────────────────────
WP_URL        = "https://votre-site-wordpress.fr"
GHOST_URL     = "http://192.168.1.XX:2368"
GHOST_API_KEY = "votre_admin_api_key"
SYNC_FILE     = os.path.join(os.path.dirname(__file__), "last_sync.json")
# ──────────────────────────────────────────────────────────────────────────────

def log(msg):
    ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{ts}] {msg}")

def ghost_token():
    key_id, secret = GHOST_API_KEY.split(":")
    iat = int(datetime.datetime.now().timestamp())
    payload = {"iat": iat, "exp": iat + 300, "aud": "/admin/"}
    return jwt.encode(payload, bytes.fromhex(secret), algorithm="HS256",
                      headers={"kid": key_id})

def ghost_headers():
    return {"Authorization": f"Ghost {ghost_token()}",
            "Content-Type": "application/json"}

def load_last_sync():
    if os.path.exists(SYNC_FILE):
        with open(SYNC_FILE) as f:
            return json.load(f).get("last_sync")
    return None

def save_last_sync(dt_str):
    with open(SYNC_FILE, "w") as f:
        json.dump({"last_sync": dt_str}, f, indent=2)

def fetch_wp_posts(after=None):
    posts, page = [], 1
    while True:
        params = {"per_page": 100, "page": page, "status": "publish",
                  "orderby": "date", "order": "asc"}
        if after:
            params["after"] = after
        r = requests.get(f"{WP_URL}/wp-json/wp/v2/posts", params=params, timeout=30)
        if r.status_code != 200:
            break
        batch = r.json()
        if not batch:
            break
        posts.extend(batch)
        if page >= int(r.headers.get("X-WP-TotalPages", 1)):
            break
        page += 1
    return posts

def ghost_post_exists(slug):
    r = requests.get(f"{GHOST_URL}/ghost/api/admin/posts/slug/{slug}/",
                     headers=ghost_headers(), timeout=10)
    if r.status_code == 200:
        posts = r.json().get("posts", [])
        if posts:
            return posts[0]["id"]
    return None

def create_ghost_post(wp_post):
    title   = wp_post.get("title", {}).get("rendered", "Sans titre")
    content = wp_post.get("content", {}).get("rendered", "")
    slug    = wp_post.get("slug", "")
    date    = wp_post.get("date_gmt", "") + "Z"
    payload = {"posts": [{"title": title, "html": content, "slug": slug,
                           "status": "published", "published_at": date}]}
    r = requests.post(f"{GHOST_URL}/ghost/api/admin/posts/?source=html",
                      headers=ghost_headers(), json=payload, timeout=15)
    if r.status_code == 201:
        log(f"  ✅ Créé : {title}")
        return True
    log(f"  ❌ Erreur : {title} — {r.status_code}")
    return False

def update_ghost_post(ghost_id, wp_post):
    title      = wp_post.get("title", {}).get("rendered", "Sans titre")
    content    = wp_post.get("content", {}).get("rendered", "")
    date       = wp_post.get("date_gmt", "") + "Z"
    r          = requests.get(f"{GHOST_URL}/ghost/api/admin/posts/{ghost_id}/",
                               headers=ghost_headers(), timeout=10)
    if r.status_code != 200:
        return False
    updated_at = r.json()["posts"][0]["updated_at"]
    payload    = {"posts": [{"title": title, "html": content, "status": "published",
                              "published_at": date, "updated_at": updated_at}]}
    r = requests.put(f"{GHOST_URL}/ghost/api/admin/posts/{ghost_id}/?source=html",
                     headers=ghost_headers(), json=payload, timeout=15)
    if r.status_code == 200:
        log(f"  🔄 Mis à jour : {title}")
        return True
    log(f"  ❌ Erreur MAJ : {title} — {r.status_code}")
    return False

def sync():
    log("=== Synchronisation WordPress → Ghost ===")
    last_sync = load_last_sync()
    log(f"Depuis : {last_sync or 'début (tous les articles)'}")
    posts = fetch_wp_posts(after=last_sync)
    log(f"{len(posts)} article(s) à traiter")
    if not posts:
        log("Rien de nouveau.")
        return
    created = updated = errors = 0
    for wp_post in posts:
        ghost_id = ghost_post_exists(wp_post.get("slug", ""))
        if ghost_id:
            ok = update_ghost_post(ghost_id, wp_post)
            if ok: updated += 1
            else:  errors  += 1
        else:
            ok = create_ghost_post(wp_post)
            if ok: created += 1
            else:  errors  += 1
        time.sleep(0.3)
    now = datetime.datetime.utcnow().isoformat()
    save_last_sync(now)
    log(f"=== Terminé : {created} créés, {updated} mis à jour, {errors} erreurs ===")

if __name__ == "__main__":
    sync()

Lancement manuel

bash

python3 ~/docker/ghost/wp_to_ghost_sync.py

Activation du cron hebdomadaire (tous les lundis à 6h)

bash

crontab -e

Ajoutez la ligne suivante :

bash

0 6 * * 1 python3 ~/docker/ghost/wp_to_ghost_sync.py >> ~/docker/ghost/sync.log 2>&1

Le script mémorise la date de la dernière synchronisation dans un fichier last_sync.json. À chaque exécution il ne récupère que les articles publiés depuis cette date — pas de doublon, pas de réimport inutile.


Résultat

Ghost tourne en local sur le port 2368, contient une copie complète de votre blog, et se met à jour automatiquement chaque semaine. En cas de panne ou d’indisponibilité de votre hébergement WordPress, votre contenu reste accessible sur votre réseau local.