Ghost detrás de Cloudflare y Traefik: La configuración completa y funcional (5 bugs corregidos)
Cómo desplegar Ghost 5 detrás de un proxy de Cloudflare y Traefik con el desafío DNS-01 de Let's Encrypt. Configuración real, errores reales, soluciones reales — incluyendo el bucle de redirección de Ghost y el fallo de enrutamiento multi-red de Traefik.
Alquilas un VPS pequeño. Apuntas tu dominio hacia él. Quieres montar un blog — Ghost, porque te importa ser dueño de tu contenido y no pagar un impuesto de plataforma para siempre. Añades Cloudflare delante porque protege la IP de tu servidor, te da un CDN gratuito y absorbe picos de tráfico que aún no tienes pero podrías tener algún día.
Luego añades Traefik como reverse proxy, porque quieres HTTPS automático y puede que más adelante ejecutes otros servicios en el mismo servidor. Sigues la documentación. Despliegas. Nada funciona.
Este post es la configuración que realmente funciona. Ghost 5 + Traefik + Cloudflare DNS-01 con Let's Encrypt. Nos topamos con cinco bugs para llegar aquí. Cada uno está documentado abajo con la solución y un comando de diagnóstico que puedes ejecutar para confirmarlo.
¿Por qué Cloudflare y Let's Encrypt a la vez?
Si Cloudflare te da un certificado TLS gratuito, ¿para qué molestarse con Let's Encrypt en el origen?
Porque cubren conexiones diferentes.
El certificado de Cloudflare vive en el edge — es lo que ven los navegadores. Se emite automáticamente, se renueva automáticamente y cubre tu dominio con la infraestructura CDN global de Cloudflare. Obtienes protección DDoS, caché y un Web Application Firewall en el plan gratuito.
El certificado de Let's Encrypt en tu servidor de origen es lo que Cloudflare ve cuando se conecta a tu backend. Con el modo SSL de Cloudflare en Full (Strict), Cloudflare verifica que el origen tenga un certificado válido y de confianza pública. Sin un certificado de origen válido, tienes dos opciones malas: - Modo Flexible (Cloudflare al origen en HTTP plano) — tus datos viajan sin cifrar entre Cloudflare y tu servidor - Modo Full (sin Strict) — Cloudflare se conecta al origen por HTTPS pero acepta certificados autofirmados o caducados, lo cual es teatro de seguridad
Con ambos funcionando juntos:
Navegador
↓ HTTPS (certificado de Cloudflare — ECDSA, rápido)
Cloudflare Edge (protección DDoS, CDN, WAF)
↓ HTTPS (certificado de Let's Encrypt — verificado)
Traefik en tu servidor
↓ HTTP (interno, solo red Docker)
Ghost
Tus datos están cifrados de extremo a extremo, la IP de tu origen permanece oculta de internet público y obtienes el CDN gratuito de Cloudflare. Esta es la configuración correcta.
¿Por qué DNS-01 en vez de HTTP-01?
Let's Encrypt puede demostrar que eres dueño de un dominio de dos formas: - HTTP-01: LE coloca un archivo en http://yourdomain/.well-known/acme-challenge/... y lo verifica por el puerto 80 - DNS-01: LE te pide crear un registro TXT en _acme-challenge.yourdomain y comprueba el DNS
Con el proxy de Cloudflare activado (nube naranja), el tráfico del puerto 80 pasa por Cloudflare antes de llegar a tu servidor. HTTP-01 sigue funcionando en este modo, pero hay un problema más sutil: si tu dominio tenía DNSSEC activado previamente (por ejemplo, de un proveedor DNS anterior como DeSEC), y cambiaste los nameservers sin actualizar los registros DS en tu registrador, la validación DNSSEC será Bogus para tu dominio. Todos los resolvers que validan DNSSEC — incluidos los servidores de Let's Encrypt — se negarán a resolverlo. HTTP-01 falla silenciosamente con un error confuso.
El challenge DNS-01 evita todo esto. Traefik llama a la API de Cloudflare, crea un registro TXT y Let's Encrypt lo valida. No se necesita acceso al puerto 80. No se requiere consulta DNSSEC para registros A. Y como bonus, DNS-01 es la única forma de obtener certificados wildcard (*.yourdomain.com).
Prompt para Claude Code u OpenClaw:
Check the Traefik logs for any ACME certificate errors and explain what they mean.
Si ves DNSSEC: Bogus: validation failure, tu registrador todavía tiene registros DS apuntando al proveedor DNS anterior. Elimínalos o actualízalos en el registrador — es la única solución.
El docker-compose.yml completo y funcional
Aquí está la configuración completa y probada. A continuación, un recorrido por cada parte no obvia.
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 vía 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)
# Router 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"
# Redirección 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
Esto es lo que hace cada anotación numerada — cada una corrige un bug real que nos encontramos.
Bug 1 — El token de Cloudflare debe llamarse CF_DNS_API_TOKEN (1)
El proveedor DNS de Cloudflare en Traefik espera un nombre de variable de entorno específico: CF_DNS_API_TOKEN. Si le pones cualquier otro nombre en tu archivo .env (yo originalmente lo llamé DNS_TOKEN de una configuración antigua de DeSEC), el proveedor de Cloudflare en Traefik no lo encontrará. La emisión del certificado fallará silenciosamente.
En el lado del .env:
CLOUDFLARE_TOKEN=your_token_here
En el lado del compose:
environment:
- CF_DNS_API_TOKEN=${CLOUDFLARE_TOKEN}
Requisitos del token de la API de Cloudflare: - Ve a: Cloudflare Dashboard → My Profile → API Tokens → Create Token - Usa la plantilla "Edit zone DNS" - Limítalo a tu zona específica — no crees un token global
Prompt para Claude Code u OpenClaw:
Check whether the Traefik container has the CF_DNS_API_TOKEN environment variable set.
Run: docker exec traefik env | grep CF_DNS
Bug 2 — Ghost crea un bucle de redirección infinito con Cloudflare (5)
Este fue el bug más difícil de encontrar. Esto es lo que pasaba:
Ghost está configurado con url: https://yourdomain.com. Cuando Traefik reenvía una petición a Ghost por HTTP plano (puerto 2368 en la red interna de Docker), Ghost ve una petición HTTP pero sabe que su URL canónica es HTTPS. Así que devuelve una redirección 301 a https://yourdomain.com.
Traefik pasa ese 301 de vuelta a Cloudflare. Cloudflare sigue la redirección (con modo Full SSL, se conecta al origen vía HTTPS). Traefik reenvía a Ghost de nuevo. Ghost devuelve otro 301. Bucle infinito. El navegador ve un timeout.
Desde fuera, esto parece exactamente un servidor caído — sin respuesta, sin código de error. Los logs de Ghost cuentan la verdad:
ghost | "GET /" 301 9ms
ghost | "GET /" 301 3ms
Cada petición recibe un 301. Nada pasa.
La solución: Indicar a Ghost que la petición ya llegó por HTTPS añadiendo una cabecera X-Forwarded-Proto: https. Ghost comprueba esta cabecera y omite la redirección cuando está presente.
- "traefik.http.middlewares.ghost-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.ghost.middlewares=ghost-headers@docker"
Puedes verificarlo sin cambiar ninguna configuración. Conéctate por SSH al servidor y ejecuta:
docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null'
Si obtienes HTTP/1.1 200 OK, la cabecera es la solución. Si sigues obteniendo 301, algo más está mal.
Prompt para Claude Code u OpenClaw:
Check Ghost logs to see what HTTP status code it's returning for GET / requests.
If it's returning 301, test whether adding an X-Forwarded-Proto: https header fixes it
by curling Ghost directly from within the Docker network.
Bug 3 — Los nombres de middleware necesitan el sufijo @docker en Traefik v3 (4)
Este es un fallo silencioso sin salida en los logs, lo que lo hace especialmente doloroso.
En Traefik v3 con el proveedor Docker, los middlewares definidos mediante labels de contenedor están dentro del ámbito del proveedor docker. Cuando los referencias desde un router, debes incluir el sufijo @docker:
# Incorrecto — falla silenciosamente, el router se cuelga
- "traefik.http.routers.ghost.middlewares=ghost-headers"
# Correcto
- "traefik.http.routers.ghost.middlewares=ghost-headers@docker"
Cuando la referencia al middleware es incorrecta, Traefik no registra un error a nivel WARN. El router simplemente no procesa las peticiones. Las conexiones se cuelgan. No se devuelve respuesta HTTP. Desde fuera, esto es idéntico al bug del bucle de redirección de Ghost — status 000, timeout.
Cómo distinguirlos: Activa los access logs de Traefik temporalmente añadiendo --accesslog=true en la sección command. Si las peticiones aparecen en los access logs pero Ghost devuelve 301, es el Bug 2. Si las peticiones no aparecen en los access logs en absoluto, es el Bug 3.
Prompt para Claude Code u OpenClaw:
Enable Traefik access logs temporarily and make a test request to the site.
Check whether the request appears in the access log. If it doesn't appear,
there's likely a broken middleware reference in the docker-compose labels.
Bug 4 — Traefik elige la IP equivocada cuando Ghost está en múltiples redes Docker (3)
Ghost está en dos redes: web (compartida con Traefik para el proxy) e internal (compartida con MySQL, aislada de internet). Cuando un contenedor tiene múltiples conexiones de red, Traefik puede resolver el hostname del contenedor a la dirección IP de la red equivocada — la que Traefik no puede enrutar correctamente.
El síntoma es intermitente: el sitio funciona tras un docker compose down && docker compose up -d completo, pero se rompe tras reiniciar solo Ghost (docker compose up -d --remove-orphans). Cada reinicio de Ghost asigna una nueva IP al contenedor. Traefik puede elegir la IP de la red internal en vez de la IP de la red web.
La solución: indicar explícitamente a Traefik qué red usar:
- "traefik.docker.network=clawstack_web"
Atención al naming: Docker Compose prefixa los nombres de red con el nombre del proyecto. Si tu proyecto compose está en un directorio llamado clawstack, la red web en tu archivo compose se convierte en clawstack_web. Puedes verificarlo con:
docker network ls | grep clawstack
Prompt para Claude Code u OpenClaw:
The site works after a full stack restart but breaks after restarting only Ghost.
Check whether the Ghost container is on multiple Docker networks and whether
traefik.docker.network is set in the Ghost labels. If not, add it.
Bug 5 — www devuelve 526 porque Cloudflare verifica el certificado de origen por hostname (SAN necesario)
El error 526 de Cloudflare significa "certificado SSL inválido en el origen". En modo Full (Strict), Cloudflare se conecta a tu origen para cada hostname proxificado y verifica el certificado. Si tu certificado de Let's Encrypt solo cubre yourdomain.com, la petición de Cloudflare para www.yourdomain.com fallará en la verificación del certificado — aunque ambos registros A apunten al mismo servidor.
La solución tiene dos partes:
Primero, añadir www.yourdomain.com como Subject Alternative Name al certificado de Let's Encrypt:
- "traefik.http.routers.ghost.tls.domains[0].main=${DOMAIN}"
- "traefik.http.routers.ghost.tls.domains[0].sans=www.${DOMAIN}"
Segundo, añadir un router dedicado para www que redirija permanentemente al 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"
Con esto, Traefik tiene un certificado que cubre ambos dominios, la verificación estricta de Cloudflare pasa, y los visitantes de www son redirigidos permanentemente al apex.
Prompt para Claude Code u OpenClaw:
Check whether the www subdomain is returning a Cloudflare 526 error.
If so, verify that the Traefik cert covers www as a SAN,
and that there's a router defined for www.yourdomain.com.
Check: cat traefik/acme.json | python3 -c "import sys,json; [print(c['domain']) for c in json.load(sys.stdin)['letsencrypt']['Certificates']]"
Configurar el modo SSL de Cloudflare
Algo que no puedes hacer con un token de API de Cloudflare limitado a solo DNS: cambiar la configuración SSL/TLS. Eso requiere permiso de edición de Zone Settings, que no deberías añadir a tu token de Traefik (principio de mínimo privilegio — Traefik solo necesita editar DNS).
Configura el modo SSL manualmente en el dashboard de Cloudflare:
Cloudflare Dashboard → yourdomain → SSL/TLS → Overview → Full (Strict)
Haz esto antes de desplegar. Si se te olvida, el valor por defecto de Cloudflare puede ser "Flexible", que se conecta al origen por HTTP, lo que desencadena el bucle de redirección de Ghost (Bug 2).
El flujo de diagnóstico
Cuando algo está roto y no sabes dónde, recorre esta lista de comprobación en orden:
1. ¿Están todos los contenedores ejecutándose?
docker compose ps
2. ¿Ghost arranca correctamente (sin errores de conexión a la BD)?
docker compose logs --tail=30 ghost
3. ¿Está el puerto 443 abierto y TLS funcionando?
echo | openssl s_client -connect localhost:443 -servername yourdomain.com 2>&1 | grep -E "subject|issuer|Verify"
4. ¿Cloudflare está llegando realmente al servidor?
sudo tcpdump -i eth0 -n 'tcp dst port 443' -c 5
5. ¿Qué código HTTP devuelve Ghost para una petición interna directa?
docker exec ghost_db bash -c 'curl -s -D - -H "X-Forwarded-Proto: https" http://ghost:2368/ -o /dev/null' | head -5
6. ¿Los routers de Traefik están recibiendo peticiones?
docker compose logs traefik | tail -20
Cada paso va acotando el problema. Si el paso 4 no muestra tráfico, es un problema de DNS o firewall. Si el paso 5 muestra 301, es el bucle de redirección. Si el paso 6 no muestra entradas en los logs a pesar de que llega tráfico, es un problema de configuración de middleware o router.
Lista de comprobación final antes de poner en producción
Antes de visitar /ghost para crear tu cuenta de administrador:
- [ ]
curl -s https://yourdomain.com/ -o /dev/null -w "%{http_code}"devuelve 200 - [ ]
curl -s https://www.yourdomain.com/ -o /dev/null -w "%{http_code}"devuelve 301 - [ ]
curl -s http://yourdomain.com/ -o /dev/null -w "%{http_code}"devuelve 301 - [ ] El emisor del certificado SSL es Let's Encrypt (no el autofirmado de Cloudflare):
curl -sv https://yourdomain.com/ 2>&1 | grep issuer - [ ] El modo SSL de Cloudflare está en Full (Strict) en el dashboard
- [ ]
traefik/acme.jsoncontiene certificados tanto para el dominio apex como para www
Usar una IA para depurar esto
Si estás atascado con cualquiera de los bugs de arriba, pega la URL de este post en la conversación con tu asistente de IA. Claude, GPT-4, Gemini — todos pueden leerlo. Entenderán el stack exacto, reconocerán los patrones de error de los fragmentos de logs y te darán comandos específicos en lugar de consejos genéricos de Docker. La lista de comprobación de diagnóstico de la sección anterior también funciona como prompt: cópiala tal cual y pide a tu IA que la recorra paso a paso en tu servidor.
Dónde ejecutar esto
El stack descrito en este post — Ghost, Traefik, MySQL — corre en un Hetzner CX22 por 4,85 €/mes. Dos vCPUs, 4 GB de RAM, 40 GB SSD. Maneja Ghost sin despeinarse y deja margen para otros contenedores en la misma máquina.
Si quieres un asistente de IA ejecutándose en tu propio servidor sin pasarte un fin de semana depurando networking de Docker, xCloud aloja OpenClaw gestionado. Obtienes las mismas capacidades que una instalación autoalojada, sin la parte donde Traefik descarta silenciosamente tus peticiones por un sufijo @docker que falta.
(Enlaces de afiliado — nos llevamos una pequeña comisión si te registras, sin coste adicional para ti.)
Recursos
- Imagen Docker de Ghost: hub.docker.com/_/ghost
- Documentación de Traefik DNS-01 con Cloudflare: doc.traefik.io
- Creación de token de la API de Cloudflare: Cloudflare Dashboard → My Profile → API Tokens
Versión en alemán (Deutsch)
Metadatos
- Título: Ghost hinter Cloudflare und Traefik: Die vollständige funktionierende Konfiguration (5 Bugs behoben)
- Slug: ghost-traefik-cloudflare-setup-de
- Descripción: Ghost 5 hinter Cloudflare Proxy und Traefik mit Let's Encrypt DNS-01-Challenge betreiben. Echte Konfiguration, echte Bugs, echte Lösungen — inklusive Ghost Redirect-Loop und Traefik Multi-Network-Routing-Fehler.
- Tags: ghost, traefik, cloudflare, docker, selbst-gehostet, ssl, infra
- Pillar: /infra/
- Idioma: de
- Tiempo de lectura: 12 min
Contenido
Du mietest einen kleinen VPS. Du zeigst deine Domain darauf. Du