The Redirect Loop: Debugging Cloudflare Tunnel + Traefik + Ghost

ERR_TOO_MANY_REDIRECTS. Ghost worked on LAN but looped endlessly through Cloudflare Tunnel. The fix was one HTTP header. Here's the full debugging story.

The Redirect Loop: Debugging Cloudflare Tunnel + Traefik + Ghost

I deployed Ghost to my Kubernetes cluster, pointed Cloudflare Tunnel at it, and opened the URL in my browser. ERR_TOO_MANY_REDIRECTS. The page wouldn't load. Not an error from Ghost, not a 500, not a timeout: just Chrome telling me the server was redirecting in circles.

This took me longer to debug than I'd like to admit, and the fix was a single Traefik middleware adding one HTTP header. Here's what happened, why, and how to fix it for any application behind this stack.

The Request Path

To understand the bug, you need to understand how a request reaches Ghost in my cluster. There are five hops, and TLS gets stripped at the first one:

Browser (HTTPS)
  → Cloudflare Edge (terminates TLS)
    → cloudflared tunnel (plain HTTP)
      → Traefik ingress controller (plain HTTP)
        → Ghost container (plain HTTP, port 2368)

The browser makes an HTTPS request to steven.naviauxlab.com. Cloudflare holds the TLS certificate and terminates the encrypted connection at its edge. From there, everything is plain HTTP. The Cloudflare Tunnel daemon (cloudflared) running in the cluster receives the decrypted request and forwards it to Traefik over HTTP:

# Cloudflare Tunnel config
ingress:
  - hostname: "*.naviauxlab.com"
    service: http://traefik.traefik.svc.cluster.local:80
    originRequest:
      noTLSVerify: true

Traefik receives the request on port 80, matches the Host header to the Ghost ingress rule, and forwards to the Ghost service. Ghost sees a plain HTTP request on port 2368.

Every hop after Cloudflare is unencrypted. This is by design. Cloudflare handles TLS for the public internet, and the cluster's internal network doesn't need to re-encrypt traffic that never leaves the node. The problem isn't the lack of encryption. The problem is that Ghost doesn't know the original request was HTTPS.

What Ghost Sees

Ghost is configured with its public URL:

env:
  - name: url
    value: "https://steven.naviauxlab.com"

This tells Ghost: "your canonical address is HTTPS." When Ghost receives a request, it checks whether the incoming scheme matches its configured URL. If you access Ghost over HTTP but it's configured for HTTPS, Ghost helpfully redirects you to the HTTPS version.

That's the correct behavior when a user accidentally types http:// instead of https://. It's catastrophic when your reverse proxy chain always delivers HTTP because TLS was terminated three hops upstream.

Here's what happens:

  1. Browser requests https://steven.naviauxlab.com
  2. Cloudflare terminates TLS, sends HTTP to the cluster
  3. Traefik forwards HTTP to Ghost
  4. Ghost sees an HTTP request, compares it to its configured https:// URL
  5. Ghost responds with 301 Redirect to https://steven.naviauxlab.com
  6. Browser follows the redirect, back to Cloudflare
  7. Cloudflare terminates TLS again, sends HTTP to the cluster again
  8. Ghost redirects again
  9. Infinite loop. ERR_TOO_MANY_REDIRECTS.

The browser gives up after about 20 redirects. Ghost never serves a page.

The Misleading Part

What made this harder to debug: it worked perfectly on LAN.

Accessing https://steven.homelab.local worked fine because that path doesn't go through Cloudflare. The browser hits Traefik directly, and cert-manager provides a self-signed TLS certificate for *.homelab.local. Traefik terminates TLS and forwards HTTP to Ghost. But in this path, Ghost doesn't redirect because the TLS termination happens at Traefik, and Traefik tells Ghost about it via the X-Forwarded-Proto header.

The Cloudflare path doesn't set that header. Cloudflare does add its own forwarding headers, but by the time the request passes through cloudflared and reaches Traefik, the X-Forwarded-Proto header either says http or is missing entirely. Ghost sees HTTP and redirects.

The Fix: One Header

The solution is a Traefik middleware that explicitly sets X-Forwarded-Proto: https on every request before it reaches Ghost:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: https-scheme
  namespace: auth
spec:
  headers:
    customRequestHeaders:
      X-Forwarded-Proto: "https"
      X-Forwarded-Ssl: "on"

Then reference it in Ghost's ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ghost
  namespace: ghost
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: auth-https-scheme@kubernetescrd,auth-security-headers@kubernetescrd

Now when Traefik forwards the request to Ghost, the header says X-Forwarded-Proto: https. Ghost reads this header, trusts the proxy, and concludes: "the client connected over HTTPS. No redirect needed." The page loads.

That's it. One middleware, two headers, zero redirect loops.

Why This Affects More Than Ghost

This isn't a Ghost bug. It's a consequence of any application that does scheme enforcement behind a multi-layer reverse proxy where TLS terminates far upstream.

Any app with url: https://... or BASE_URL: https://... or FORCE_SSL: true will exhibit the same behavior behind Cloudflare Tunnel if it doesn't receive the X-Forwarded-Proto: https header. In my cluster, the affected pattern is:

  • App is configured with an HTTPS canonical URL
  • App sits behind Cloudflare Tunnel (TLS terminated at Cloudflare edge)
  • Traefik forwards plain HTTP internally
  • App checks the request scheme and redirects to HTTPS

The https-scheme middleware is the universal fix. I apply it to every ingress that goes through Cloudflare Tunnel. It's harmless for apps that don't check the scheme, and essential for apps that do.

The Diagnostic Process

For anyone hitting ERR_TOO_MANY_REDIRECTS behind a reverse proxy, here's how I'd debug it now:

1. Confirm it's a redirect loop, not something else.

# Follow redirects and show each hop
curl -sIL https://steven.naviauxlab.com 2>&1 | grep -E "HTTP/|Location:"

If you see the same Location: header repeating, it's a redirect loop.

2. Check what the app actually receives.

# Port-forward to the app and send a request with the proxy headers stripped
kubectl port-forward -n ghost svc/ghost 8080:80
curl -sI http://localhost:8080/

If you get a 301 redirect to HTTPS here, the app is enforcing HTTPS and doesn't see the right headers.

3. Test with the header manually.

curl -sI -H "X-Forwarded-Proto: https" http://localhost:8080/

If the redirect disappears, you've confirmed the fix. Add the https-scheme middleware to the ingress.

4. Verify end-to-end.

curl -sI https://steven.naviauxlab.com | head -5

You should see HTTP/2 200 instead of HTTP/2 301.

Lessons

Three things I took away from this:

TLS termination at the edge means every downstream hop is lying about the scheme. The browser used HTTPS. Cloudflare used HTTPS. But by the time the request reaches the app, it's HTTP and nothing in the request proves otherwise. The X-Forwarded-Proto header is the only way the app can know the truth, and someone has to set it.

"Works on LAN, broken on Cloudflare" is a header problem. If an app works when accessed directly through Traefik but breaks through the tunnel, the difference is almost always in the forwarded headers. The request path is different, the headers are different, and the app behaves differently because of it.

Redirect loops are loud but the cause is quiet. ERR_TOO_MANY_REDIRECTS is one of the most alarming browser errors, but the root cause is usually a single misconfigured or missing header. The fix is always small. Finding it is the hard part.