Ghost hinter Cloudflare und Traefik: Das komplette funktionierende Setup (5 Bugs behoben)

Wie du Ghost 5 hinter einem Cloudflare-Proxy und Traefik mit Let's Encrypt DNS-01-Challenge deployest. Echte Konfiguration, echte Bugs, echte Fixes — inklusive der Ghost-Redirect-Schleife und des Traefik-Multi-Network-Routing-Fehlers.

Ghost hinter Cloudflare und Traefik: Das komplette funktionierende Setup (5 Bugs behoben)
Auch verfügbar auf English, Français, Español, Nederlands.

Du mietest einen kleinen VPS. Du zeigst deine Domain darauf. Du willst einen Blog betreiben — Ghost, weil du deine Inhalte besitzen willst und nicht ewig an eine Plattform zahlst. Du schaltest Cloudflare davor, weil es deine Server-IP schützt, ein kostenloses CDN mitbringt und Traffic-Spitzen abfängt, die du noch nicht hast, aber vielleicht irgendwann bekommst.

Dann kommt Traefik als Reverse Proxy dazu, weil du automatisches HTTPS willst und vielleicht später noch andere Dienste auf demselben Server betreiben möchtest. Du folgst der Dokumentation. Du deployst. Nichts funktioniert.

Dieser Post ist die Konfiguration, die tatsächlich funktioniert. Ghost 5 + Traefik + Cloudflare DNS-01 mit Let's Encrypt. Ich bin auf fünf Bugs gestoßen. Jeder ist unten mit der Lösung und einem Diagnose-Befehl dokumentiert.


Warum gleichzeitig Cloudflare und Let's Encrypt?

Wenn Cloudflare ein kostenloses TLS-Zertifikat bereitstellt, warum dann noch Let's Encrypt auf dem eigenen Server?

Weil sie unterschiedliche Verbindungen absichern.

Das Cloudflare-Zertifikat sitzt am Edge — das ist, was Browser sehen. Es wird automatisch ausgestellt, automatisch erneuert und deckt deine Domain mit Cloudflares globaler CDN-Infrastruktur ab. Im kostenlosen Tarif bekommst du DDoS-Schutz, Caching und eine Web Application Firewall.

Das Let's Encrypt-Zertifikat auf deinem Ursprungsserver ist, was Cloudflare sieht, wenn es sich mit deinem Backend verbindet. Mit Cloudflares SSL-Modus auf Full (Strict) prüft Cloudflare, ob der Ursprungsserver ein gültiges, öffentlich vertrauenswürdiges Zertifikat hat. Ohne gültiges Ursprungszertifikat bleiben nur schlechte Optionen: - Flexible-Modus (Cloudflare zum Ursprung unverschlüsselt) — deine Daten sind zwischen Cloudflare und deinem Server im Klartext - Full-Modus (nicht Strict) — Cloudflare verbindet sich per HTTPS zum Ursprung, akzeptiert aber selbstsignierte oder abgelaufene Zertifikate — das ist Sicherheitstheater

Mit beidem zusammen:

Browser
  ↓ HTTPS (Cloudflare-Zertifikat — ECDSA, schnell)
Cloudflare Edge (DDoS-Schutz, CDN, WAF)
  ↓ HTTPS (Let's Encrypt-Zertifikat — verifiziert)
Traefik auf deinem Server
  ↓ HTTP (intern, nur Docker-Netzwerk)
Ghost

Deine Daten sind Ende-zu-Ende verschlüsselt, deine Server-IP bleibt vor dem öffentlichen Internet verborgen, und du bekommst Cloudflares kostenloses CDN. Das ist die korrekte Konfiguration.


Warum DNS-01-Challenge statt HTTP-01

Let's Encrypt kann Domaineigentümerschaft auf zwei Wegen beweisen: - HTTP-01: LE platziert eine Datei unter http://yourdomain/.well-known/acme-challenge/... und prüft sie über Port 80 - DNS-01: LE fordert einen TXT-Eintrag bei _acme-challenge.yourdomain an und prüft DNS

Mit aktiviertem Cloudflare-Proxy (orange Wolke) läuft Port-80-Traffic durch Cloudflare, bevor er deinen Server erreicht. HTTP-01 funktioniert in diesem Modus noch, aber es gibt ein subtileres Problem: Wenn deine Domain früher DNSSEC aktiviert hatte (z.B. von einem alten DNS-Anbieter wie DeSEC), und du die Nameserver gewechselt hast, ohne die DS-Einträge beim Registrar zu aktualisieren, ist die DNSSEC-Validierung für deine Domain Bogus. Jeder DNSSEC-validierende Resolver — einschließlich Let's Encrypts Server — wird die Auflösung verweigern. HTTP-01 schlägt still fehl mit einer verwirrenden Fehlermeldung.

DNS-01-Challenge umgeht das vollständig. Traefik ruft die Cloudflare-API auf, erstellt einen TXT-Eintrag, und Let's Encrypt validiert ihn. Kein Port-80-Zugriff nötig. Kein DNSSEC-Lookup für A-Einträge erforderlich. Und als Bonus ist DNS-01 der einzige Weg für Wildcard-Zertifikate (*.yourdomain.com).

Prompt für Claude Code oder OpenClaw:

Prüfe die Traefik-Logs auf ACME-Zertifikatsfehler und erkläre, was sie bedeuten.

Wenn du DNSSEC: Bogus: validation failure siehst, hat dein Registrar noch DS-Einträge, die auf den alten DNS-Anbieter zeigen. Entferne oder aktualisiere sie beim Registrar — das ist der einzige Fix.


Die vollständige funktionierende docker-compose.yml

Hier ist die komplette, getestete Konfiguration. Ich erkläre jedes nicht offensichtliche Detail.

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)
      # Apex-Router
      - "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"
      # X-Forwarded-Proto Middleware            (5)
      - "traefik.http.middlewares.ghost-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      # www → Apex-Weiterleitung
      - "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

Ich erkläre die fünf nummerierten Annotationen — jede behebt einen echten Bug, auf den ich gestoßen bin.


Bug 1 — Der Cloudflare-Token muss CF_DNS_API_TOKEN heißen (1)

Traefiks Cloudflare DNS-Provider erwartet einen bestimmten Umgebungsvariablennamen: CF_DNS_API_TOKEN. Wenn du ihn in deiner .env-Datei anders nennst (ich hatte ihn ursprünglich DNS_TOKEN von einer alten DeSEC-Konfiguration), findet der Cloudflare-Provider ihn nicht. Die Zertifikatsausstellung schlägt stillschweigend fehl.

Die .env-Seite:

CLOUDFLARE_TOKEN=dein_token_hier

Die Compose-Seite:

environment:
  - CF_DNS_API_TOKEN=${CLOUDFLARE_TOKEN}

Cloudflare API-Token-Anforderungen: - Cloudflare Dashboard → My Profile → API Tokens → Token erstellen - Vorlage "Edit zone DNS" verwenden - Auf deine spezifische Zone beschränken — keinen globalen Token erstellen

Prompt für Claude Code oder OpenClaw:

Prüfe, ob der Traefik-Container die CF_DNS_API_TOKEN-Umgebungsvariable gesetzt hat.
Führe aus: docker exec traefik env | grep CF_DNS

Bug 2 — Ghost erzeugt eine Endlosweiterleitung mit Cloudflare (5)

Das war der schwierigste Bug. Folgendes passiert:

Ghost ist mit url: https://yourdomain.com konfiguriert. Wenn Traefik eine Anfrage per HTTP an Ghost weiterleitet (Port 2368 im internen Docker-Netzwerk), sieht Ghost eine HTTP-Anfrage, weiß aber, dass seine kanonische URL HTTPS ist. Also gibt es eine 301-Weiterleitung auf https://yourdomain.com zurück.

Traefik reicht diese 301 an Cloudflare weiter. Cloudflare folgt der Weiterleitung (im Full-SSL-Modus verbindet es sich per HTTPS zum Ursprung). Traefik leitet erneut an Ghost weiter. Ghost gibt wieder eine 301 zurück. Endlosschleife. Der Browser sieht einen Timeout.

Von außen sieht das exakt wie ein nicht erreichbarer Server aus — keine Antwort, kein Fehlercode. Die Ghost-Logs verraten die Wahrheit:

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

Jede Anfrage bekommt eine 301. Nichts kommt durch.

Die Lösung: Ghost mitteilen, dass die Anfrage bereits über HTTPS ankam, indem ein X-Forwarded-Proto: https-Header hinzugefügt wird. Ghost prüft diesen Header und überspringt die Weiterleitung.

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

Du kannst das ohne Konfigurationsänderung verifizieren. Per SSH auf den Server und:

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

Bei HTTP/1.1 200 OK ist der Header der Fix. Bei weiterhin 301 liegt etwas anderes im Argen.

Prompt für Claude Code oder OpenClaw:

Prüfe die Ghost-Logs, welchen HTTP-Statuscode Ghost für GET / zurückgibt.
Wenn es 301 zurückgibt, teste ob das Hinzufügen eines X-Forwarded-Proto: https Headers
es behebt, indem Ghost direkt innerhalb des Docker-Netzwerks aufgerufen wird.

Bug 3 — Middleware-Namen brauchen das @docker-Suffix in Traefik v3 (4)

Dieser Bug ist ein stiller Fehler ohne Log-Ausgabe — das macht ihn besonders schmerzhaft.

In Traefik v3 mit dem Docker-Provider sind Middlewares, die über Container-Labels definiert werden, auf den docker-Provider beschränkt. Bei der Referenzierung aus einem Router muss das @docker-Suffix enthalten sein:

# Falsch — schlägt still fehl, Router hängt
- "traefik.http.routers.ghost.middlewares=ghost-headers"

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

Bei falscher Middleware-Referenz loggt Traefik keinen Fehler auf WARN-Level. Der Router verarbeitet einfach keine Anfragen. Verbindungen hängen. Keine HTTP-Antwort wird zurückgegeben. Von außen sieht das identisch aus wie der Ghost-Redirect-Loop aus Bug 2 — Status 000, Timeout.

Wie man sie unterscheidet: Traefik Access-Logs temporär aktivieren mit --accesslog=true im Command-Abschnitt. Wenn Anfragen in den Access-Logs erscheinen, aber Ghost 301 zurückgibt — Bug 2. Wenn keine Anfragen in den Access-Logs erscheinen — Bug 3.

Prompt für Claude Code oder OpenClaw:

Aktiviere Traefik Access-Logs temporär und mache eine Testanfrage an die Seite.
Prüfe ob die Anfrage im Access-Log erscheint. Wenn nicht,
gibt es wahrscheinlich eine fehlerhafte Middleware-Referenz in den docker-compose Labels.

Bug 4 — Traefik wählt die falsche IP wenn Ghost in mehreren Docker-Netzwerken ist (3)

Ghost ist in zwei Netzwerken: web (geteilt mit Traefik für das Proxying) und internal (geteilt mit MySQL, vom Internet isoliert). Wenn ein Container mehrere Netzwerkanbindungen hat, kann Traefik den Container-Hostnamen in die IP-Adresse des falschen Netzwerks auflösen — desjenigen, zu dem Traefik nicht korrekt routen kann.

Das Symptom ist intermittierend: Die Seite funktioniert nach einem vollständigen docker compose down && docker compose up -d, bricht aber nach einem Ghost-Neustart (docker compose up -d --remove-orphans) ab. Bei jedem Ghost-Neustart bekommt der Container eine neue IP. Traefik kann die internal-Netzwerk-IP statt der web-Netzwerk-IP aufgreifen.

Die Lösung: Traefik explizit mitteilen, welches Netzwerk verwendet werden soll:

- "traefik.docker.network=clawstack_web"

Beachte die Benennung: Docker Compose stellt Netzwerknamen den Projektnamen voran. Wenn dein Compose-Projekt in einem Verzeichnis namens clawstack liegt, wird das Netzwerk web in deiner Compose-Datei zu clawstack_web. Überprüfen mit:

docker network ls | grep clawstack

Prompt für Claude Code oder OpenClaw:

Die Seite funktioniert nach einem vollständigen Stack-Neustart, bricht aber nach
dem Neustart nur von Ghost ab. Prüfe ob der Ghost-Container in mehreren Docker-Netzwerken
ist und ob traefik.docker.network in den Ghost-Labels gesetzt ist. Falls nicht, füge es hinzu.

Bug 5 — www gibt 526 zurück weil Cloudflare das Ursprungszertifikat pro Hostname prüft (SAN erforderlich)

Cloudflare-Fehler 526 bedeutet "ungültiges SSL-Zertifikat am Ursprung". Im Full (Strict)-Modus verbindet sich Cloudflare für jeden proxierten Hostnamen mit deinem Ursprung und prüft das Zertifikat. Wenn dein Let's Encrypt-Zertifikat nur yourdomain.com abdeckt, schlägt Cloudflares Anfrage für www.yourdomain.com bei der Zertifikatsprüfung fehl — obwohl beide A-Einträge auf denselben Server zeigen.

Die Lösung hat zwei Teile:

Erstens, www.yourdomain.com als Subject Alternative Name zum Let's Encrypt-Zertifikat hinzufügen:

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

Zweitens, einen dedizierten Router für www mit dauerhafter Weiterleitung zum 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"

Damit hat Traefik ein Zertifikat, das beide Domains abdeckt, Cloudflares strikte Verifizierung besteht, und www-Besucher werden dauerhaft zum Apex weitergeleitet.

Prompt für Claude Code oder OpenClaw:

Prüfe ob die www-Subdomain einen Cloudflare 526-Fehler zurückgibt.
Falls ja, überprüfe ob das Traefik-Zertifikat www als SAN abdeckt,
und ob ein Router für www.yourdomain.com definiert ist.
Prüfe: cat traefik/acme.json | python3 -c "import sys,json; [print(c['domain']) for c in json.load(sys.stdin)['letsencrypt']['Certificates']]"

Cloudflare SSL-Modus einstellen

Eine Sache, die du mit einem auf DNS beschränkten Cloudflare API-Token nicht tun kannst: SSL/TLS-Einstellungen ändern. Dafür brauchst du die Zone Settings Edit-Berechtigung, die du nicht zum Traefik-Token hinzufügen solltest (Principle of Least Privilege — Traefik braucht nur DNS Edit).

SSL-Modus manuell im Cloudflare Dashboard einstellen:

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

Das vor dem Deployment tun. Wenn du es vergisst, kann Cloudflares Standard "Flexible" sein, was sich per HTTP zum Ursprung verbindet und direkt den Ghost-Redirect-Loop (Bug 2) auslöst.


Der Diagnose-Workflow

Wenn etwas kaputt ist und du nicht weißt wo, arbeite diese Checkliste der Reihe nach durch:

1. Laufen alle Container?
   docker compose ps

2. Startet Ghost erfolgreich (keine DB-Verbindungsfehler)?
   docker compose logs --tail=30 ghost

3. Ist Port 443 offen und TLS funktionsfähig?
   echo | openssl s_client -connect localhost:443 -servername yourdomain.com 2>&1 | grep -E "subject|issuer|Verify"

4. Erreicht Cloudflare den Server tatsächlich?
   sudo tcpdump -i eth0 -n 'tcp dst port 443' -c 5

5. Welchen HTTP-Status gibt Ghost bei einer direkten internen Anfrage zurück?
   docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null' | head -5

6. Sehen Traefiks Router irgendwelche Anfragen?
   docker compose logs traefik | tail -20

Jeder Schritt grenzt das Problem ein. Wenn Schritt 4 keinen Traffic zeigt, ist es ein DNS- oder Firewall-Problem. Wenn Schritt 5 eine 301 zeigt, ist es der Redirect-Loop. Wenn Schritt 6 trotz ankommendem Traffic keine Log-Einträge zeigt, ist es ein Middleware- oder Router-Konfigurationsproblem.


Abschluss-Checkliste vor dem Go-Live

Bevor du /ghost besuchst, um dein Admin-Konto zu erstellen:

  • [ ] curl -s https://yourdomain.com/ -o /dev/null -w "%{http_code}" gibt 200 zurück
  • [ ] curl -s https://www.yourdomain.com/ -o /dev/null -w "%{http_code}" gibt 301 zurück
  • [ ] curl -s http://yourdomain.com/ -o /dev/null -w "%{http_code}" gibt 301 zurück
  • [ ] SSL-Zertifikatsaussteller ist Let's Encrypt (nicht Cloudflares selbstsigniertes): curl -sv https://yourdomain.com/ 2>&1 | grep issuer
  • [ ] Cloudflare SSL-Modus ist Full (Strict) im Dashboard
  • [ ] traefik/acme.json enthält Zertifikate für Apex- und www-Domain

KI zum Debuggen einsetzen

Wenn du bei einem der oben beschriebenen Bugs feststeckst, füge die URL dieses Posts in deine KI-Konversation ein. Claude, GPT-4, Gemini — alle können ihn lesen. Sie erkennen die Fehlermuster aus den Log-Ausschnitten und geben dir gezielte Befehle statt generischer Docker-Ratschläge. Die Diagnose-Checkliste aus dem vorherigen Abschnitt funktioniert auch direkt als Prompt: kopiere sie wörtlich und bitte deine KI, sie Schritt für Schritt auf deinem Server durchzuarbeiten.


Wo das Ganze läuft

Der in diesem Post beschriebene Stack — Ghost, Traefik, MySQL — läuft auf einem Hetzner CX22 für €4,85/Monat. Zwei vCPUs, 4 GB RAM, 40 GB SSD. Ghost läuft darauf mühelos, und es bleibt Platz für weitere Container auf derselben Maschine.

Wer einen KI-Assistenten auf dem eigenen Server betreiben will, ohne vorher ein Wochenende mit Docker-Networking-Debugging zu verbringen — xCloud hostet OpenClaw managed. Du bekommst dieselben Möglichkeiten wie bei einem selbstgehosteten Setup, ohne die Stelle, an der Traefik deine Anfragen wegen eines fehlenden @docker-Suffix still verwirft.

(Affiliate-Links — ich bekomme eine kleine Provision, wenn du dich anmeldest, ohne Mehrkosten für dich.)


Ressourcen