Ghost on Kubernetes with Traefik, Cloudflare, and cert-manager: the routing gotcha I hit and how I fixed it

I run Ghost behind Traefik in Kubernetes with Cloudflare in front and cert-manager issuing TLS via Let’s Encrypt DNS. It mostly worked, until Ghost started calling back to its own API and getting 500s from the edge. The fix was simple once I understood the hop-by-hop scheme.

TL;DR

  • Terminate TLS at Traefik.
  • Keep Ghost speaking HTTP in-cluster.
  • Make Ghost’s public URL https.
  • Force Traefik to use HTTP to the Ghost Service.
  • Avoid rewrites on the /.ghost/* paths.

Symptoms

  • Browser CORS or mixed-content errors when Ghost generated http:// URLs.
  • Boot-time errors like:
    • Could not get webhook secret for ActivityPub ... invalid json response body ... Unexpected token 'I', "Internal S"...
    • IMAGE_SIZE_URL ... ProbeError: bad status code: 500 against http://blog.example.com/favicon.ico
  • Direct pod hits to http://127.0.0.1:2368/ worked; https://.../.ghost/... via the Ingress returned 500.

Root cause

  • Ghost exposes cleartext on port 2368 and detects HTTPS via forwarded headers.
  • Traefik was trying to speak TLS to the Ghost Service for some routes because the backend was implicitly treated as https.
  • Result: Traefik 500 for /.ghost/* requests; Ghost expects JSON and chokes on an HTML 500 page.

Working values.yaml

Use chart values only; no manual patches. The key is to set the public URL to https while keeping the Service upstream scheme http.

# Make Ghost generate https links without changing the in-cluster protocol
ghostEnableHttps: false

# Service: keep it plain HTTP to the pod
service:
  type: ClusterIP
  ports:
    http: 2368
  port: 2368
  annotations:
    traefik.ingress.kubernetes.io/service.serversscheme: http

# Traefik Ingress: TLS at the edge, HTTP to backend
ingress:
  enabled: true
  ingressClassName: traefik
  hostname: blog.armiger.io
  path: /
  pathType: Prefix
  tls: true
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.tls: "true"
    # do NOT add rewrite-target or StripPrefix here

Notes

  • That service.serversscheme: http annotation is the linchpin with Traefik.
  • ghostEnableHttps: false prevents the chart from steering the backend to a pseudo-https port.
  • The lowercase url env tells Ghost its canonical URL is https, fixing CORS and mixed content.

Optional: dedicated route for internal API paths

Some ingress stacks attach middlewares that accidentally rewrite dot-prefixed paths.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ghost-internal
  namespace: ghost
  annotations:
    kubernetes.io/ingress.class: traefik
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/service.serversscheme: "http"
spec:
  tls:
  - hosts: [blog.armiger.io]
  rules:
  - host: blog.armiger.io
    http:
      paths:
      - path: /.ghost/
        pathType: Prefix
        backend:
          service:
            name: ghost
            port:
              name: http

Cloudflare settings I use

  • SSL/TLS mode: Full (strict).
  • Page Rule or Transform Rule: bypass cache for /.ghost/*.
  • Do not force redirects or HSTS at Cloudflare until the origin is confirmed healthy.

Quick verification

Through Traefik, bypass Cloudflare with a one-off resolve:

curl -vk https://blog.armiger.io/.ghost/activitypub/v1/site/ \
  --resolve blog.armiger.io:443:<traefik-lb-ip>
# Expect: 200 and JSON, not a 500

Inside cluster, Ghost redirects HTTP to HTTPS for the API and that is fine:

curl -sS -D- http://127.0.0.1:2368/.ghost/activitypub/v1/site -o /dev/null
# Expect: 301 Location: https://127.0.0.1:2368/...

Gotchas to avoid

  • Do not attach rewrite-target or StripPrefix middlewares to the Ghost host.
  • Do not point Traefik at a Service port named https unless you also force service.serversscheme: http.
  • If you flip ghostEnableHttps: true, you will likely reintroduce an https upstream and the 500s.

Why this works

  • Clients use HTTPS end-to-end.
  • Traefik terminates TLS and forwards HTTP to Ghost on 2368.
  • Ghost knows its canonical site URL is https and emits correct URLs for admin and API.
  • The dot-prefixed internal API paths are routed without rewrites, so no JSON endpoints are mangled.

References