Ghost derrière Cloudflare et Traefik : le setup complet qui fonctionne (5 bugs corrigés)

Comment déployer Ghost 5 derrière un proxy Cloudflare et Traefik avec le challenge Let's Encrypt DNS-01. De vraies configs, de vrais bugs, de vrais correctifs — y compris la boucle de redirection Ghost et l'échec du routage multi-réseau de Traefik.

Ghost derrière Cloudflare et Traefik : le setup complet qui fonctionne (5 bugs corrigés)
Également disponible en English, Deutsch, Español, Nederlands.

Vous louez un petit VPS. Vous pointez votre domaine dessus. Vous voulez faire tourner un blog — Ghost, parce que vous tenez à posséder votre contenu et à ne pas payer une taxe de plateforme indéfiniment. Vous ajoutez Cloudflare devant parce qu'il protège l'IP de votre serveur d'origine, vous offre un CDN gratuit, et absorbe les pics de trafic que vous n'avez pas encore mais que vous pourriez avoir un jour.

Ensuite vous ajoutez Traefik comme Reverse Proxy, parce que vous voulez du HTTPS automatique et que vous pourriez faire tourner d'autres services sur le même serveur plus tard. Vous suivez la documentation. Vous déployez. Rien ne fonctionne.

Cet article est la configuration qui fonctionne réellement. Ghost 5 + Traefik + Cloudflare DNS-01 avec Let's Encrypt. Nous avons rencontré cinq bugs pour en arriver là. Chacun est documenté ci-dessous avec le correctif et une commande de diagnostic que vous pouvez exécuter pour le confirmer.


Pourquoi Cloudflare ET Let's Encrypt ?

Si Cloudflare vous fournit un certificat TLS gratuit, pourquoi s'embêter avec Let's Encrypt sur le serveur d'origine ?

Parce qu'ils couvrent des connexions différentes.

Le certificat Cloudflare se trouve en bordure de réseau (edge) — c'est ce que les navigateurs voient. Il est émis automatiquement, renouvelé automatiquement, et couvre votre domaine avec l'infrastructure CDN mondiale de Cloudflare. Vous bénéficiez d'une protection DDoS, du caching et d'un Web Application Firewall sur l'offre gratuite.

Le certificat Let's Encrypt sur votre serveur d'origine est ce que Cloudflare voit lorsqu'il se connecte à votre backend. Avec le mode SSL de Cloudflare réglé sur Full (Strict), Cloudflare vérifie que le serveur d'origine dispose d'un certificat valide et publiquement reconnu. Sans certificat d'origine valide, vous n'avez que deux mauvaises options : - Mode Flexible (Cloudflare vers l'origine en HTTP simple) — vos données circulent en clair entre Cloudflare et votre serveur - Mode Full (sans Strict) — Cloudflare se connecte en HTTPS à l'origine mais accepte les certificats auto-signés ou expirés, ce qui n'est que du théâtre sécuritaire

Avec les deux fonctionnant ensemble :

Navigateur
  ↓ HTTPS (certificat Cloudflare — ECDSA, rapide)
Cloudflare Edge (protection DDoS, CDN, WAF)
  ↓ HTTPS (certificat Let's Encrypt — vérifié)
Traefik sur votre serveur
  ↓ HTTP (interne, réseau Docker uniquement)
Ghost

Vos données sont chiffrées de bout en bout, l'IP de votre serveur d'origine reste cachée de l'internet public, et vous bénéficiez du CDN gratuit de Cloudflare. C'est la configuration correcte.


Pourquoi le challenge DNS-01 plutôt que HTTP-01

Let's Encrypt peut prouver que vous possédez un domaine de deux manières : - HTTP-01 : LE place un fichier à http://yourdomain/.well-known/acme-challenge/... et le vérifie via le port 80 - DNS-01 : LE vous demande de créer un enregistrement TXT à _acme-challenge.yourdomain et vérifie le DNS

Avec le Proxy Cloudflare activé (nuage orange), le trafic sur le port 80 passe par Cloudflare avant d'atteindre votre serveur. HTTP-01 fonctionne encore dans ce mode, mais il y a un problème plus subtil : si votre domaine avait précédemment DNSSEC activé (par exemple chez un ancien fournisseur DNS comme DeSEC), et que vous avez changé de serveurs de noms sans mettre à jour les enregistrements DS chez votre registrar, la validation DNSSEC sera Bogus pour votre domaine. Chaque résolveur validant DNSSEC — y compris les serveurs de Let's Encrypt — refusera de le résoudre. HTTP-01 échoue silencieusement avec une erreur confuse.

Le challenge DNS-01 contourne entièrement ce problème. Traefik appelle l'API Cloudflare, crée un enregistrement TXT, et Let's Encrypt le valide. Pas besoin d'accès au port 80. Pas de recherche DNSSEC sur les enregistrements A nécessaire. Et en bonus, DNS-01 est le seul moyen d'obtenir des certificats wildcard (*.yourdomain.com).

Prompt pour Claude Code ou OpenClaw :

Vérifie les logs Traefik pour toute erreur de certificat ACME et explique ce qu'elles signifient.

Si vous voyez DNSSEC: Bogus: validation failure, votre registrar a encore des enregistrements DS pointant vers l'ancien fournisseur DNS. Supprimez-les ou mettez-les à jour chez le registrar — c'est le seul correctif.


Le fichier docker-compose.yml complet et fonctionnel

Voici la configuration complète et testée. Ci-dessous, un tour d'horizon de chaque élément non évident.

services:

  traefik:
    image: traefik:latest
    container_name: traefik
    restart: unless-stopped
    environment:
      - DOCKER_API_VERSION=1.41
      - CF_DNS_API_TOKEN=${CLOUDFLARE_TOKEN}   # (1)
    ports:
      - "80:80"
      - "443:443"
    command:
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      # DNS-01 via Cloudflare                  (2)
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
      - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
      - "--log.level=WARN"
      - "--accesslog=false"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/acme.json:/acme.json
    networks:
      - web

  ghost:
    image: ghost:5-alpine
    container_name: ghost
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      url: https://${DOMAIN}
      database__client: mysql
      database__connection__host: db
      database__connection__user: ${DB_USER}
      database__connection__password: ${DB_PASSWORD}
      database__connection__database: ${DB_NAME}
      mail__transport: SMTP
      mail__options__host: ${MAIL_HOST}
      mail__options__port: ${MAIL_PORT}
      mail__options__secureConnection: "false"
      mail__options__auth__user: ${MAIL_USER}
      mail__options__auth__pass: ${MAIL_PASS}
      mail__from: ${MAIL_FROM}
      NODE_ENV: production
    volumes:
      - ghost_content:/var/lib/ghost/content
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=clawstack_web"                         # (3)
      # Routeur apex
      - "traefik.http.routers.ghost.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.ghost.entrypoints=websecure"
      - "traefik.http.routers.ghost.tls=true"
      - "traefik.http.routers.ghost.tls.certresolver=letsencrypt"
      - "traefik.http.routers.ghost.tls.domains[0].main=${DOMAIN}"
      - "traefik.http.routers.ghost.tls.domains[0].sans=www.${DOMAIN}"
      - "traefik.http.routers.ghost.middlewares=ghost-headers@docker"  # (4)
      - "traefik.http.services.ghost.loadbalancer.server.port=2368"
      # Middleware X-Forwarded-Proto            (5)
      - "traefik.http.middlewares.ghost-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      # Redirection www → apex
      - "traefik.http.routers.ghost-www.rule=Host(`www.${DOMAIN}`)"
      - "traefik.http.routers.ghost-www.entrypoints=websecure"
      - "traefik.http.routers.ghost-www.tls=true"
      - "traefik.http.routers.ghost-www.tls.certresolver=letsencrypt"
      - "traefik.http.routers.ghost-www.middlewares=www-redirect@docker"
      - "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.*)"
      - "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
      - "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"
    networks:
      - web
      - internal

  db:
    image: mysql:8.0
    container_name: ghost_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    networks:
      - internal

volumes:
  ghost_content:
  db_data:

networks:
  web:
  internal:
    internal: true

Voici ce que fait chaque annotation numérotée — chacune corrige un vrai bug que nous avons rencontré.


Bug 1 — Le token Cloudflare doit s'appeler CF_DNS_API_TOKEN (1)

Le fournisseur DNS Cloudflare de Traefik attend un nom de variable d'environnement précis : CF_DNS_API_TOKEN. Si vous lui donnez un autre nom dans votre fichier .env (nous l'avions initialement appelé DNS_TOKEN suite à une ancienne configuration DeSEC), le fournisseur Cloudflare de Traefik ne le trouvera pas. L'émission du certificat échouera silencieusement.

Côté .env :

CLOUDFLARE_TOKEN=votre_token_ici

Côté Compose :

environment:
  - CF_DNS_API_TOKEN=${CLOUDFLARE_TOKEN}

Exigences pour le token API Cloudflare : - Allez dans : Cloudflare Dashboard → My Profile → API Tokens → Create Token - Utilisez le modèle « Edit zone DNS » - Limitez-le à votre zone spécifique — ne créez pas un token global

Prompt pour Claude Code ou OpenClaw :

Vérifie si le conteneur Traefik a la variable d'environnement CF_DNS_API_TOKEN définie.
Exécute : docker exec traefik env | grep CF_DNS

Bug 2 — Ghost crée une boucle de redirection infinie avec Cloudflare (5)

C'était le bug le plus difficile à trouver. Voici ce qui se passait :

Ghost est configuré avec url: https://yourdomain.com. Lorsque Traefik transmet une requête à Ghost en HTTP simple (port 2368 sur le réseau Docker interne), Ghost voit une requête HTTP mais sait que son URL canonique est en HTTPS. Il renvoie donc une redirection 301 vers https://yourdomain.com.

Traefik retransmet cette 301 à Cloudflare. Cloudflare suit la redirection (en mode Full SSL, il se connecte à l'origine via HTTPS). Traefik retransmet à Ghost. Ghost renvoie une autre 301. Boucle infinie. Le navigateur affiche un timeout.

De l'extérieur, cela ressemble exactement à un serveur hors service — pas de réponse, pas de code d'erreur. Les logs de Ghost révèlent la vérité :

ghost | "GET /" 301 9ms
ghost | "GET /" 301 3ms

Chaque requête reçoit une 301. Rien ne passe.

Le correctif : Indiquer à Ghost que la requête est déjà arrivée en HTTPS en ajoutant un en-tête X-Forwarded-Proto: https. Ghost vérifie cet en-tête et saute la redirection lorsqu'il est présent.

- "traefik.http.middlewares.ghost-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.ghost.middlewares=ghost-headers@docker"

Vous pouvez vérifier cela sans aucune modification de configuration. Connectez-vous en SSH au serveur et exécutez :

docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null'

Si vous obtenez HTTP/1.1 200 OK, l'en-tête est le correctif. Si vous obtenez encore 301, autre chose ne va pas.

Prompt pour Claude Code ou OpenClaw :

Vérifie les logs Ghost pour voir quel code de statut HTTP est retourné pour les requêtes GET /.
Si c'est 301, teste si l'ajout d'un en-tête X-Forwarded-Proto: https corrige le problème
en appelant Ghost directement depuis le réseau Docker interne.

Bug 3 — Les noms de middleware nécessitent le suffixe @docker dans Traefik v3 (4)

Celui-ci est une défaillance silencieuse sans sortie de log, ce qui le rend particulièrement pénible.

Dans Traefik v3 avec le fournisseur Docker, les middlewares définis via les labels de conteneur sont rattachés au fournisseur docker. Lorsque vous les référencez depuis un routeur, vous devez inclure le suffixe @docker :

# Incorrect — échoue silencieusement, le routeur reste bloqué
- "traefik.http.routers.ghost.middlewares=ghost-headers"

# Correct
- "traefik.http.routers.ghost.middlewares=ghost-headers@docker"

Lorsque la référence du middleware est incorrecte, Traefik ne journalise aucune erreur au niveau WARN. Le routeur ne traite tout simplement pas les requêtes. Les connexions restent en attente. Aucune réponse HTTP n'est retournée. De l'extérieur, cela ressemble exactement au bug de boucle de redirection Ghost — statut 000, timeout.

Comment les distinguer : Activez temporairement les logs d'accès Traefik en ajoutant --accesslog=true dans la section command. Si les requêtes apparaissent dans les logs d'accès mais que Ghost retourne 301, c'est le Bug 2. Si les requêtes n'apparaissent pas du tout dans les logs d'accès, c'est le Bug 3.

Prompt pour Claude Code ou OpenClaw :

Active temporairement les logs d'accès Traefik et fais une requête de test vers le site.
Vérifie si la requête apparaît dans le log d'accès. Si elle n'apparaît pas,
il y a probablement une référence de middleware cassée dans les labels docker-compose.

Bug 4 — Traefik choisit la mauvaise IP lorsque Ghost est sur plusieurs réseaux Docker (3)

Ghost est sur deux réseaux : web (partagé avec Traefik pour le proxying) et internal (partagé avec MySQL, isolé d'internet). Lorsqu'un conteneur a plusieurs attachements réseau, Traefik peut résoudre le nom d'hôte du conteneur vers l'adresse IP du mauvais réseau — celui que Traefik ne peut pas router correctement.

Le symptôme est intermittent : le site fonctionne après un docker compose down && docker compose up -d complet, mais casse après un redémarrage de Ghost seul (docker compose up -d --remove-orphans). Chaque redémarrage de Ghost attribue une nouvelle IP au conteneur. Traefik peut récupérer l'IP du réseau internal au lieu de celle du réseau web.

Le correctif : indiquer explicitement à Traefik quel réseau utiliser :

- "traefik.docker.network=clawstack_web"

Notez le nommage : Docker Compose préfixe les noms de réseau avec le nom du projet. Si votre projet Compose se trouve dans un répertoire appelé clawstack, le réseau web dans votre fichier Compose devient clawstack_web. Vous pouvez vérifier avec :

docker network ls | grep clawstack

Prompt pour Claude Code ou OpenClaw :

Le site fonctionne après un redémarrage complet de la stack mais casse après le redémarrage de Ghost seul.
Vérifie si le conteneur Ghost est sur plusieurs réseaux Docker et si
traefik.docker.network est défini dans les labels Ghost. Sinon, ajoute-le.

Bug 5 — www retourne 526 parce que Cloudflare vérifie le certificat d'origine par nom d'hôte (SAN requis)

L'erreur Cloudflare 526 signifie « certificat SSL invalide sur l'origine ». En mode Full (Strict), Cloudflare se connecte à votre origine pour chaque nom d'hôte proxifié et vérifie le certificat. Si votre certificat Let's Encrypt ne couvre que yourdomain.com, alors la requête de Cloudflare pour www.yourdomain.com échouera à la vérification du certificat — même si les deux enregistrements A pointent vers le même serveur.

Le correctif comporte deux parties :

Premièrement, ajouter www.yourdomain.com comme Subject Alternative Name au certificat Let's Encrypt :

- "traefik.http.routers.ghost.tls.domains[0].main=${DOMAIN}"
- "traefik.http.routers.ghost.tls.domains[0].sans=www.${DOMAIN}"

Deuxièmement, ajouter un routeur dédié pour www qui redirige de façon permanente vers l'apex :

- "traefik.http.routers.ghost-www.rule=Host(`www.${DOMAIN}`)"
- "traefik.http.routers.ghost-www.tls=true"
- "traefik.http.routers.ghost-www.middlewares=www-redirect@docker"
- "traefik.http.middlewares.www-redirect.redirectregex.regex=^https://www\\.(.*)"
- "traefik.http.middlewares.www-redirect.redirectregex.replacement=https://$${1}"
- "traefik.http.middlewares.www-redirect.redirectregex.permanent=true"

Avec cela, Traefik dispose d'un certificat couvrant les deux domaines, la vérification stricte de Cloudflare passe, et les visiteurs de www sont redirigés de façon permanente vers l'apex.

Prompt pour Claude Code ou OpenClaw :

Vérifie si le sous-domaine www retourne une erreur Cloudflare 526.
Si c'est le cas, vérifie que le certificat Traefik couvre www en tant que SAN,
et qu'un routeur est défini pour www.yourdomain.com.
Vérifie : cat traefik/acme.json | python3 -c "import sys,json; [print(c['domain']) for c in json.load(sys.stdin)['letsencrypt']['Certificates']]"

Configuration du mode SSL Cloudflare

Une chose que vous ne pouvez pas faire avec un token API Cloudflare limité au DNS uniquement : modifier les paramètres SSL/TLS. Cela nécessite la permission d'édition des paramètres de zone, que vous ne devriez pas ajouter à votre token Traefik (principe du moindre privilège — Traefik n'a besoin que de l'édition DNS).

Configurez le mode SSL manuellement dans le tableau de bord Cloudflare :

Cloudflare Dashboard → yourdomain → SSL/TLS → Overview → Full (Strict)

Faites-le avant de déployer. Si vous oubliez, le mode par défaut de Cloudflare peut être « Flexible », qui se connecte à l'origine en HTTP, ce qui déclenche la boucle de redirection Ghost (Bug 2).


Le workflow de diagnostic

Lorsque quelque chose est cassé et que vous ne savez pas où, parcourez cette checklist dans l'ordre :

1. Tous les conteneurs tournent-ils ?
   docker compose ps

2. Ghost démarre-t-il correctement (pas d'erreurs de connexion à la base) ?
   docker compose logs --tail=30 ghost

3. Le port 443 est-il ouvert et le TLS fonctionne-t-il ?
   echo | openssl s_client -connect localhost:443 -servername yourdomain.com 2>&1 | grep -E "subject|issuer|Verify"

4. Cloudflare atteint-il réellement le serveur ?
   sudo tcpdump -i eth0 -n 'tcp dst port 443' -c 5

5. Quel statut HTTP Ghost retourne-t-il pour une requête interne directe ?
   docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null' | head -5

6. Les routeurs Traefik reçoivent-ils des requêtes ?
   docker compose logs traefik | tail -20

Chaque étape réduit le périmètre. Si l'étape 4 ne montre aucun trafic, c'est un problème de DNS ou de pare-feu. Si l'étape 5 montre 301, c'est la boucle de redirection. Si l'étape 6 ne montre aucune entrée de log malgré le trafic entrant, c'est un problème de configuration de middleware ou de routeur.


Checklist finale avant mise en production

Avant de visiter /ghost pour créer votre compte administrateur :

  • [ ] curl -s https://yourdomain.com/ -o /dev/null -w "%{http_code}" retourne 200
  • [ ] curl -s https://www.yourdomain.com/ -o /dev/null -w "%{http_code}" retourne 301
  • [ ] curl -s http://yourdomain.com/ -o /dev/null -w "%{http_code}" retourne 301
  • [ ] L'émetteur du certificat SSL est Let's Encrypt (pas le certificat auto-signé de Cloudflare) : curl -sv https://yourdomain.com/ 2>&1 | grep issuer
  • [ ] Le mode SSL Cloudflare est Full (Strict) dans le tableau de bord
  • [ ] traefik/acme.json contient les certificats pour les domaines apex et www

Utiliser une IA pour déboguer

Si vous êtes bloqué sur l'un des bugs ci-dessus, collez l'URL de cet article dans la conversation de votre assistant IA. Claude, GPT-4, Gemini — tous peuvent le lire. Ils comprendront la stack exacte, reconnaîtront les schémas d'erreurs à partir des extraits de logs, et vous donneront des commandes ciblées plutôt que des conseils Docker génériques. La checklist de diagnostic de la section précédente fonctionne aussi comme prompt : copiez-la telle quelle et demandez à votre IA de la parcourir étape par étape sur votre serveur.


Où faire tourner tout ça

La stack décrite dans cet article — Ghost, Traefik, MySQL — tourne sur un Hetzner CX22 pour 4,85 €/mois. Deux vCPUs, 4 Go de RAM, 40 Go SSD. Ghost tourne sans effort, et il reste de la place pour d'autres conteneurs sur la même machine.

Si vous voulez un assistant IA tournant sur votre propre serveur sans passer un week-end à déboguer le réseau Docker, xCloud