Ghost achter Cloudflare en Traefik: De complete werkende setup (5 bugs opgelost)

Hoe je Ghost 5 deployt achter een Cloudflare-proxy en Traefik met Let's Encrypt DNS-01-challenge. Echte configuratie, echte bugs, echte oplossingen — inclusief de Ghost-redirectloop en het falen van Traefik multi-network routing.

Ghost achter Cloudflare en Traefik: De complete werkende setup (5 bugs opgelost)
Ook beschikbaar in het English, Deutsch, Français, Español.

Je huurt een kleine VPS. Je wijst je domein ernaartoe. Je wilt een blog draaien — Ghost, omdat je eigenaar wilt zijn van je content en niet eeuwig platformbelasting wilt betalen. Je zet Cloudflare ervoor omdat het je origin-IP beschermt, je een gratis CDN geeft, en traffic-pieken opvangt die je nog niet hebt maar ooit misschien wel.

Dan voeg je Traefik toe als reverse proxy, omdat je automatische HTTPS wilt en misschien later andere services op dezelfde server wilt draaien. Je volgt de documentatie. Je deployt. Niets werkt.

Dit artikel is de setup die daadwerkelijk werkt. Ghost 5 + Traefik + Cloudflare DNS-01 met Let's Encrypt. We liepen tegen vijf bugs aan. Elke bug is hieronder gedocumenteerd met de fix en een diagnostisch commando dat je kunt uitvoeren om het te bevestigen.


Waarom zowel Cloudflare als Let's Encrypt?

Als Cloudflare je een gratis TLS-certificaat geeft, waarom dan nog moeite doen met Let's Encrypt op de origin?

Omdat ze verschillende verbindingen beveiligen.

Het Cloudflare-certificaat zit op de edge — dat is wat browsers zien. Het wordt automatisch uitgegeven, automatisch vernieuwd, en dekt je domein af met hun wereldwijde CDN-infrastructuur. Je krijgt DDoS-bescherming, caching en een Web Application Firewall op de gratis tier.

Het Let's Encrypt-certificaat op je origin-server is wat Cloudflare ziet wanneer het verbinding maakt met je backend. Met Cloudflare's SSL-modus ingesteld op Full (Strict) verifieert Cloudflare dat de origin een geldig, publiek vertrouwd certificaat heeft. Zonder geldig origin-certificaat heb je twee slechte opties: - Flexible-modus (Cloudflare naar origin is gewoon HTTP) — je data is onversleuteld tussen Cloudflare en je server - Full-modus (niet Strict) — Cloudflare maakt verbinding met origin via HTTPS maar accepteert zelfondertekende of verlopen certificaten, wat security theater is

Met beide samen werkend:

Browser
  ↓ HTTPS (Cloudflare's cert — ECDSA, snel)
Cloudflare Edge (DDoS-bescherming, CDN, WAF)
  ↓ HTTPS (Let's Encrypt cert — geverifieerd)
Traefik op je server
  ↓ HTTP (intern, alleen Docker-netwerk)
Ghost

Je data is end-to-end versleuteld, je origin-IP blijft verborgen voor het publieke internet, en je krijgt Cloudflare's gratis CDN. Dit is de correcte setup.


Waarom DNS-01 Challenge in plaats van HTTP-01

Let's Encrypt kan op twee manieren bewijzen dat je eigenaar bent van een domein: - HTTP-01: LE plaatst een bestand op http://yourdomain/.well-known/acme-challenge/... en verifieert het via poort 80 - DNS-01: LE vraagt je een TXT-record aan te maken op _acme-challenge.yourdomain en controleert DNS

Met Cloudflare proxy ingeschakeld (oranje wolk) gaat poort 80-verkeer door Cloudflare voordat het je server bereikt. HTTP-01 werkt nog steeds in deze modus, maar er is een subtieler probleem: als je domein eerder DNSSEC ingeschakeld had (bijv. van een eerdere DNS-provider zoals DeSEC), en je de nameservers hebt gewisseld zonder de DS-records bij je registrar bij te werken, zal DNSSEC-validatie Bogus zijn voor je domein. Elke DNSSEC-validerende resolver — inclusief de servers van Let's Encrypt — zal weigeren het te resolven. HTTP-01 faalt stilzwijgend met een verwarrende foutmelding.

DNS-01 challenge omzeilt dit volledig. Traefik roept de Cloudflare API aan, maakt een TXT-record aan, en Let's Encrypt valideert het. Geen poort 80-toegang nodig. Geen DNSSEC-lookup voor A-records vereist. En als bonus is DNS-01 de enige manier om wildcard-certificaten (*.yourdomain.com) te krijgen.

Prompt voor Claude Code of OpenClaw:

Controleer de Traefik-logs op ACME-certificaatfouten en leg uit wat ze betekenen.

Als je DNSSEC: Bogus: validation failure ziet, heeft je registrar nog DS-records die naar de oude DNS-provider wijzen. Verwijder ze of werk ze bij bij de registrar — dat is de enige fix.


De volledige werkende docker-compose.yml

Hier is de complete, geteste configuratie. Hieronder een toelichting op elk niet-vanzelfsprekend onderdeel.

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 redirect
      - "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

Hieronder wat elke genummerde annotatie doet — elke annotatie lost een echte bug op die we tegenkwamen.


Bug 1 — De Cloudflare-token moet CF_DNS_API_TOKEN heten (1)

Traefik's Cloudflare DNS-provider verwacht een specifieke naam voor de omgevingsvariabele: CF_DNS_API_TOKEN. Als je het iets anders noemt in je .env-bestand (wij noemden het oorspronkelijk DNS_TOKEN vanuit een oude DeSEC-setup), vindt Traefik's Cloudflare-provider het niet. De certificaatuitgifte faalt stilzwijgend.

De .env-kant:

CLOUDFLARE_TOKEN=je_token_hier

De compose-kant:

environment:
  - CF_DNS_API_TOKEN=${CLOUDFLARE_TOKEN}

Cloudflare API-token vereisten: - Ga naar: Cloudflare Dashboard → My Profile → API Tokens → Create Token - Gebruik het "Edit zone DNS"-template - Beperk het tot je specifieke zone — maak geen globale token aan

Prompt voor Claude Code of OpenClaw:

Controleer of de Traefik-container de CF_DNS_API_TOKEN omgevingsvariabele ingesteld heeft.
Voer uit: docker exec traefik env | grep CF_DNS

Bug 2 — Ghost creëert een oneindige redirect-loop met Cloudflare (5)

Dit was de moeilijkste bug om te vinden. Dit is wat er gebeurde:

Ghost is geconfigureerd met url: https://yourdomain.com. Wanneer Traefik een request doorstuurt naar Ghost via gewoon HTTP (poort 2368 op het interne Docker-netwerk), ziet Ghost een HTTP-request maar weet dat zijn canonieke URL HTTPS is. Dus stuurt het een 301 redirect naar https://yourdomain.com terug.

Traefik geeft die 301 door aan Cloudflare. Cloudflare volgt de redirect (met Full SSL-modus maakt het verbinding met de origin via HTTPS). Traefik stuurt opnieuw door naar Ghost. Ghost stuurt weer een 301 terug. Oneindige loop. De browser ziet een timeout.

Van buitenaf ziet dit er precies uit als een server die down is — geen response, geen foutcode. De Ghost-logs vertellen de waarheid:

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

Elk request krijgt een 301. Niets komt erdoor.

De fix: Vertel Ghost dat het request al via HTTPS is binnengekomen door een X-Forwarded-Proto: https header toe te voegen. Ghost controleert deze header en slaat de redirect over wanneer deze aanwezig is.

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

Je kunt dit verifiëren zonder configuratiewijziging. SSH naar de server en voer uit:

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

Als je HTTP/1.1 200 OK krijgt, is de header de fix. Als je nog steeds 301 krijgt, is er iets anders mis.

Prompt voor Claude Code of OpenClaw:

Controleer de Ghost-logs om te zien welke HTTP-statuscode het teruggeeft voor GET / requests.
Als het 301 teruggeeft, test of het toevoegen van een X-Forwarded-Proto: https header het oplost
door Ghost rechtstreeks vanuit het Docker-netwerk te curlen.

Bug 3 — Middleware-namen hebben het @docker-suffix nodig in Traefik v3 (4)

Dit is een stille fout zonder log-output, wat het extra pijnlijk maakt.

In Traefik v3 met de Docker-provider zijn middlewares die via container-labels gedefinieerd worden, gekoppeld aan de docker-provider. Wanneer je ze vanuit een router refereert, moet je het @docker-suffix toevoegen:

# Fout — faalt stilzwijgend, router hangt
- "traefik.http.routers.ghost.middlewares=ghost-headers"

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

Wanneer de middleware-referentie fout is, logt Traefik geen fout op WARN-niveau. De router verwerkt gewoon geen requests. Verbindingen hangen. Er wordt geen HTTP-response teruggestuurd. Van buitenaf ziet dit er identiek uit aan de Ghost redirect-loop bug — status 000, timeout.

Hoe je ze onderscheidt: Schakel Traefik access logs tijdelijk in door --accesslog=true toe te voegen aan de command-sectie. Als requests verschijnen in de access logs maar Ghost 301 teruggeeft, is het Bug 2. Als requests helemaal niet verschijnen in de access logs, is het Bug 3.

Prompt voor Claude Code of OpenClaw:

Schakel Traefik access logs tijdelijk in en doe een testrequest naar de site.
Controleer of het request verschijnt in de access log. Als het niet verschijnt,
is er waarschijnlijk een kapotte middleware-referentie in de docker-compose labels.

Bug 4 — Traefik kiest het verkeerde IP wanneer Ghost op meerdere Docker-netwerken zit (3)

Ghost zit op twee netwerken: web (gedeeld met Traefik voor proxying) en internal (gedeeld met MySQL, geïsoleerd van het internet). Wanneer een container meerdere netwerkkoppelingen heeft, kan Traefik de container-hostname resolven naar het IP-adres van het verkeerde netwerk — het netwerk waar Traefik niet correct naartoe kan routen.

Het symptoom is intermitterend: de site werkt na een volledige docker compose down && docker compose up -d, maar breekt na een Ghost-only herstart (docker compose up -d --remove-orphans). Elke Ghost-herstart geeft de container een nieuw IP. Traefik kan het internal-netwerk-IP oppakken in plaats van het web-netwerk-IP.

De fix: vertel Traefik expliciet welk netwerk het moet gebruiken:

- "traefik.docker.network=clawstack_web"

Let op de naamgeving: Docker Compose voegt de projectnaam toe als prefix aan netwerknamen. Als je compose-project in een map genaamd clawstack staat, wordt het netwerk web in je compose-bestand clawstack_web. Je kunt het verifiëren met:

docker network ls | grep clawstack

Prompt voor Claude Code of OpenClaw:

De site werkt na een volledige stack-herstart maar breekt na het herstarten van alleen Ghost.
Controleer of de Ghost-container op meerdere Docker-netwerken zit en of
traefik.docker.network is ingesteld in de Ghost-labels. Zo niet, voeg het toe.

Bug 5 — www geeft 526 terug omdat Cloudflare het origin-certificaat per hostnaam verifieert (SAN vereist)

Cloudflare-fout 526 betekent "ongeldig SSL-certificaat op de origin". In Full (Strict)-modus maakt Cloudflare verbinding met je origin voor elke geproxyde hostnaam en verifieert het certificaat. Als je Let's Encrypt-certificaat alleen yourdomain.com dekt, zal Cloudflare's request voor www.yourdomain.com de certificaatverificatie niet doorstaan — ook al wijzen beide A-records naar dezelfde server.

De fix bestaat uit twee delen:

Ten eerste, voeg www.yourdomain.com toe als Subject Alternative Name aan het Let's Encrypt-certificaat:

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

Ten tweede, voeg een dedicated router toe voor www die permanent redirect naar het apex-domein:

- "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"

Hiermee heeft Traefik een certificaat dat beide domeinen dekt, slaagt Cloudflare's strikte verificatie, en worden www-bezoekers permanent doorgestuurd naar het apex-domein.

Prompt voor Claude Code of OpenClaw:

Controleer of het www-subdomein een Cloudflare 526-fout teruggeeft.
Zo ja, verifieer dat het Traefik-certificaat www als SAN dekt,
en dat er een router gedefinieerd is voor www.yourdomain.com.
Controleer: cat traefik/acme.json | python3 -c "import sys,json; [print(c['domain']) for c in json.load(sys.stdin)['letsencrypt']['Certificates']]"

Cloudflare SSL-modus instellen

Eén ding dat je niet kunt doen met een beperkte DNS-only Cloudflare API-token: SSL/TLS-instellingen wijzigen. Dat vereist Zone Settings edit-permissie, die je niet aan je Traefik-token moet toevoegen (principle of least privilege — Traefik heeft alleen DNS edit nodig).

Stel de SSL-modus handmatig in via het Cloudflare-dashboard:

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

Doe dit vóór het deployen. Als je het vergeet, kan Cloudflare's standaard "Flexible" zijn, wat verbinding maakt met de origin via HTTP, wat de Ghost redirect-loop triggert (Bug 2).


De diagnostische workflow

Wanneer iets stuk is en je niet zeker weet waar, doorloop deze checklist op volgorde:

1. Draaien alle containers?
   docker compose ps

2. Start Ghost succesvol op (geen DB-verbindingsfouten)?
   docker compose logs --tail=30 ghost

3. Is poort 443 open en werkt TLS?
   echo | openssl s_client -connect localhost:443 -servername yourdomain.com 2>&1 | grep -E "subject|issuer|Verify"

4. Bereikt Cloudflare daadwerkelijk de server?
   sudo tcpdump -i eth0 -n 'tcp dst port 443' -c 5

5. Welke HTTP-status geeft Ghost terug bij een directe interne request?
   docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null' | head -5

6. Zien Traefik's routers requests binnenkomen?
   docker compose logs traefik | tail -20

Elke stap verkleint het zoekgebied. Als stap 4 geen verkeer toont, is het een DNS- of firewallprobleem. Als stap 5 een 301 toont, is het de redirect-loop. Als stap 6 geen logregels toont ondanks binnenkomend verkeer, is het een middleware- of routerconfigprobleem.


Laatste checklist voor je live gaat

Voordat je /ghost bezoekt om je admin-account aan te maken:

  • [ ] curl -s https://yourdomain.com/ -o /dev/null -w "%{http_code}" geeft 200 terug
  • [ ] curl -s https://www.yourdomain.com/ -o /dev/null -w "%{http_code}" geeft 301 terug
  • [ ] curl -s http://yourdomain.com/ -o /dev/null -w "%{http_code}" geeft 301 terug
  • [ ] SSL-certificaatuitgever is Let's Encrypt (niet Cloudflare's zelfondertekend): curl -sv https://yourdomain.com/ 2>&1 | grep issuer
  • [ ] Cloudflare SSL-modus is Full (Strict) in het dashboard
  • [ ] traefik/acme.json bevat certificaten voor zowel het apex- als het www-domein

AI gebruiken om dit te debuggen

Als je vastzit op een van de bovenstaande bugs, plak de URL van dit artikel in het gesprek met je AI-assistent. Claude, GPT-4, Gemini — ze kunnen het allemaal lezen. Ze herkennen de foutpatronen uit de log-fragmenten en geven je gerichte commando's in plaats van generiek Docker-advies. De diagnostische checklist in de vorige sectie werkt ook als prompt: kopieer het letterlijk en vraag je AI om het stap voor stap op je server door te lopen.


Waar je dit kunt draaien

De stack beschreven in dit artikel — Ghost, Traefik, MySQL — draait op een Hetzner CX22 voor €4,85/maand. Twee vCPU's, 4 GB RAM, 40 GB SSD. Ghost draait er moeiteloos op, en er is ruimte over voor andere containers op dezelfde machine.

Als je een AI-assistent wilt draaien op je eigen server zonder eerst een weekend te besteden aan het debuggen van Docker-networking, host xCloud OpenClaw managed. Je krijgt dezelfde mogelijkheden als bij een self-hosted setup, zonder het gedeelte waar Traefik stilzwijgend je requests dropt vanwege een ontbrekend @docker-suffix.

(Affiliate links — we ontvangen een kleine vergoeding als je je aanmeldt, zonder extra kosten voor jou.)


Bronnen


Duitse versie (Deutsch)


Metadata

  • Titel: Ghost achter Cloudflare en Traefik: De volledige werkende configuratie (5 bugs opgelost)
  • Slug: ghost-traefik-cloudflare-setup-nl
  • Beschrijving: Ghost 5 draaien achter Cloudflare Proxy en Traefik met Let's Encrypt DNS-01 Challenge. Echte configuratie, echte bugs, echte oplossingen — inclusief Ghost redirect-loop en Traefik multi-netwerk