Cloudflare Tunnel: 502 from a localhost Ingress, and a Redirect Loop That Switching SSL Mode Did Not Fix

Error message cloudflared log — public URL returns HTTP/2 502
dial tcp [::1]:80: connect: connection refused
Error message Browser — curl exits with code 47 (too many redirects)
ERR_TOO_MANY_REDIRECTS

Short answer: A 502 came from the tunnel ingress pointing at http://localhost:80, which inside Docker is the cloudflared container itself; using the Compose service name (http://nginx:80) fixed it. A redirect loop followed: switching the SSL mode from Flexible to Full alone did not fix it in our test, while changing the nginx redirect condition from $scheme to $http_x_forwarded_proto did (that fix was tested with SSL mode set to Full only).

Hostnames in commands and outputs have been replaced with app.example.com; other redactions (, , , …) are as captured.

Request path diagram


Part A: HTTP 502 Bad Gateway (originService=http://localhost:80)

Symptom

When cloudflared runs inside a Docker Compose stack alongside an nginx container and the remote-managed tunnel ingress points to http://localhost:80, requests to the public hostname return HTTP/2 502 from Cloudflare:

$ curl -sI https://app.example.com/    (x3, all identical)
HTTP/2 502
content-type: text/plain; charset=UTF-8
server: cloudflare
cf-ray: <RAY>

Confirm Which Layer Is Failing

Inspecting the cloudflared container version and logs shows the tunnel is registered and using configuration version=1 ("service":"http://localhost:80"), followed by connection refused errors when attempting to dial [::1]:80:

$ docker compose exec cloudflared cloudflared --version
cloudflared version 2025.10.0 (built 2025-10-14-19:16 UTC)

$ docker compose logs cloudflared | grep -iE "registered|ingress|config|error"
INF Registered tunnel connection connIndex=0 connection=<UUID-REDACTED> ip=<IP> location=pdx03 protocol=quic
INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"app.example.com\", \"service\":\"http://localhost:80\"}, {\"service\":\"http_status:404\"}], \"warp-routing\":{\"enabled\":false}}" version=1
INF Registered tunnel connection connIndex=1 ... location=sea10 protocol=quic
INF Registered tunnel connection connIndex=2 ... location=pdx03 protocol=quic
INF Registered tunnel connection connIndex=3 ... location=sea11 protocol=quic
$ docker compose logs cloudflared --since 1m | grep -iE 'origin|error'
2026-10-29T09:23:22Z ERR Request failed error="Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflared: dial tcp [::1]:80: connect: connection refused" connIndex=2 dest=https://app.example.com/ type=http
2026-10-29T09:23:22Z ERR  error="Unable to reach the origin service. ... dial tcp [::1]:80: connect: connection refused" ingressRule=0 originService=http://localhost:80

The dial error indicates the failure is on the cloudflared -> origin hop: cloudflared cannot open a connection to the origin it was given.

Cause

As general Docker networking knowledge (consistent with the dial tcp [::1]:80: connect: connection refused error above), each container in a standard Docker Compose network has its own network namespace. Because cloudflared runs in its own container, localhost ([::1]) refers to the cloudflared container itself rather than the separate nginx service container. Nothing is listening on port 80 inside the cloudflared container, so the connection is refused (inferred from the error; we did not inspect the container’s listening sockets).

The One Change

Update the tunnel’s origin ingress service (see Cloudflare’s ingress rule format; our tunnel was remote-managed, but the service field is the same) from http://localhost:80 to the Docker Compose service name http://nginx:80 (applied here via PUT /accounts/<ACCOUNT>/cfd_tunnel/<TUNNEL_ID>/configurations, incrementing the remote configuration from version=1 to version=2).

Verify

Check the cloudflared logs to confirm version=2 was applied:

$ docker compose logs cloudflared | grep 'Updated to new configuration' | tail -1
2026-10-29T09:24:14Z INF Updated to new configuration config="{\"ingress\":[{\"hostname\":\"app.example.com\", \"service\":\"http://nginx:80\"}, {\"service\":\"http_status:404\"}], ...}" version=2
(last 502/ERR lines in log are at 09:24:00Z, before version=2 was applied; none after)

Testing the public URL again confirms the 502 is gone and requests now reach the nginx container (which logs the incoming request and returns 301 due to the redirect rule examined in Part B):

$ curl -sI https://app.example.com/
HTTP/2 301
location: https://app.example.com/
server: cloudflare

$ docker compose logs nginx --tail 1
<IP> - - [29/Oct/2026:09:24:17 +0000] "HEAD / HTTP/1.1" 301 0 "-" "curl/8.14.1" "<IPv6-REDACTED>"

(Note: The 502 state was toggled to the fixed state once during testing, not twice.)


Part B: Redirect Loop (curl exit code 47; browsers typically show ERR_TOO_MANY_REDIRECTS)

Symptom

The request path from here on is: client -> Cloudflare edge -> tunnel -> cloudflared -> nginx:80 -> traefik/whoami (nginx proxies to the whoami test app). Once cloudflared reaches nginx over http://nginx:80, requests to https://app.example.com/ enter an endless 301 redirect loop pointing to the exact same https:// URL, terminating with curl exit code 47 (too many redirects):

$ curl -sIL --max-redirs 10 https://app.example.com/ | grep -iE "^HTTP|^location"
HTTP/2 301
location: https://app.example.com/
HTTP/2 301
location: https://app.example.com/
... (11 identical 301 -> same https URL)
curl rc=47 (47 = too many redirects)

Confirm Which Layer Is Failing

The last entries of the nginx access log show the requests arriving at the origin nginx and being answered with 301, so the redirect comes from the origin, not from Cloudflare:

$ docker compose logs nginx --tail 3
<IP> - - [29/Oct/2026:09:24:17 +0000] "HEAD / HTTP/1.1" 301 0 "-" "curl/8.14.1" "<IPv6-REDACTED>"   (x3)

Control test: switching SSL mode from Flexible to Full did not fix it

Before touching nginx, the hostname-scoped Cloudflare Configuration Rule (repro-batch1 ssl: (http.host eq "app.example.com")) was set to SSL=flexible. As a control test, we changed that rule from flexible to full while leaving nginx unchanged:

# Control (not the fix): Configuration Rule "repro-batch1 ssl" changed flexible -> full (hostname-scoped), nginx unchanged:
301 redirects=10 rc=47        # loop persists -> with a Tunnel the SSL mode is not the lever here

(Same curl -sIL --max-redirs 10 check as above, result summarized in the capture.) Switching the SSL mode from Flexible to Full alone did not stop the loop.

Why (general knowledge, consistent with this result): the SSL mode controls how Cloudflare’s edge connects to an origin. With a Tunnel there is no inbound edge-to-origin connection; cloudflared dials the origin itself, using the scheme in the ingress service URL (http://nginx:80 here). Changing Flexible to Full does not change that hop, so nginx still sees plain HTTP.

Cause

In this setup, origin nginx.conf contains a force-HTTPS redirect keyed on nginx’s $scheme variable:

  # BROKEN: force-HTTPS keyed on $scheme; cloudflared->nginx is always plain http
  if ($scheme != "https") { return 301 https://$host$request_uri; }

Because the tunnel ingress service is configured as http://nginx:80, the hop between cloudflared and nginx inside the Docker network is always plain HTTP—regardless of whether the Cloudflare SSL mode is Flexible or Full (general mechanism inferred from the tunnel ingress config, verified for this setup by the control test above). Consequently:

  1. The visitor connects to Cloudflare over https://.
  2. cloudflared forwards the request to http://nginx:80 over plain HTTP, passing the original visitor scheme in the X-Forwarded-Proto: https header (and Cf-Visitor: {"scheme":"https"}).
  3. nginx evaluates $scheme, which reflects the local cloudflared -> nginx connection (http), not the external visitor connection. Because $scheme is "http", nginx returns 301 https://app.example.com/ on every request. That Location is the exact URL the client just requested, so the next request looks identical, nginx makes the same decision, and the loop never ends.

The One Change

Change the origin nginx.conf condition to inspect $http_x_forwarded_proto (the value of the X-Forwarded-Proto request header) instead of $scheme, then reload nginx. For a visitor on HTTPS that header is https (see the whoami output below), so the condition is false and nginx stops sending the 301. The capture below is an excerpt of the diff (file headers and hunk markers omitted):

$ diff -u nginx.conf.bak-loop-20261029 nginx.conf
-  # BROKEN: force-HTTPS keyed on $scheme; cloudflared->nginx is always plain http
-  if ($scheme != "https") { return 301 https://$host$request_uri; }
+  # FIXED: force-HTTPS keyed on X-Forwarded-Proto set by Cloudflare/cloudflared
+  if ($http_x_forwarded_proto != "https") { return 301 https://$host$request_uri; }
$ docker compose exec -T nginx nginx -s reload

Verify

Re-run the curl check against https://app.example.com/:

$ curl -sIL --max-redirs 10 https://app.example.com/
HTTP/2 200
content-type: text/plain; charset=utf-8
server: cloudflare

Inspecting the upstream traefik/whoami response body confirms the headers received through the tunnel:

$ curl -s https://app.example.com/     (traefik/whoami echo, filtered)
Hostname: <container-id>
GET / HTTP/1.1
Host: app.example.com
Cf-Visitor: {"scheme":"https"}
X-Forwarded-Proto: https

Toggling nginx.conf between $scheme (broken) and $http_x_forwarded_proto (fixed) twice with nginx -s reload confirms the fix:

# toggle (swap conf + reload)
round1 broken: 301 redirects=10 rc=47
round1 fixed: 200 redirects=0 rc=0
round2 broken: 301 redirects=10 rc=47
round2 fixed: 200 redirects=0 rc=0

A plain-HTTP visitor request still gets one redirect to https://. The capture records only this response (the follow-up HTTPS request is the 200 case shown above); we did not capture which layer issued this particular 301:

# plain-http visitor after fix (still upgraded to https once, no loop):
HTTP/1.1 301 Moved Permanently
location: https://app.example.com/

Re-test from a second machine

To verify from outside the Debian 13 test machine, the loop and fixed states were also tested from a second machine over the public Internet using --max-redirs 5 (captured 2026-10-29 17:29 UTC+8; the capture labels this time “CST”, meaning China Standard Time). In the loop state curl received six consecutive 301 responses, stopped with Maximum (5) redirects followed and exit code 47; after the fix the same command returned 200 with exit code 0:

## LOOP state (origin nginx: if ($scheme != "https"))
$ curl -v -sIL --max-redirs 5 https://app.example.com/
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* Server certificate:
* Connected to app.example.com (<IP>) port 443
> HEAD / HTTP/2
> Host: app.example.com
< HTTP/2 301 
< location: https://app.example.com/
< server: cloudflare
* Issue another request to this URL: 'https://app.example.com/'
# ... (4 identical request/301 blocks omitted)
> HEAD / HTTP/2
> Host: app.example.com
< HTTP/2 301 
< location: https://app.example.com/
< server: cloudflare
* Maximum (5) redirects followed
HTTP/2 301 
location: https://app.example.com/
HTTP/2 301 
location: https://app.example.com/
HTTP/2 301 
location: https://app.example.com/
HTTP/2 301 
location: https://app.example.com/
HTTP/2 301 
location: https://app.example.com/
HTTP/2 301 
location: https://app.example.com/
curl rc=47

## FIXED state (origin nginx: if ($http_x_forwarded_proto != "https")), 17:29 CST
$ curl -v -sIL --max-redirs 5 https://app.example.com/
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* Connected to app.example.com (<IP>) port 443
> HEAD / HTTP/2
> Host: app.example.com
< HTTP/2 200 
< content-type: text/plain; charset=utf-8
< server: cloudflare
curl rc=0

Minimal Working Config

Cloudflare Tunnel remote configuration (ingress part, reconstructed from the version=2 log line, which was itself truncated)

{"ingress":[{"hostname":"app.example.com", "service":"http://nginx:80"}, {"service":"http_status:404"}]}

Origin nginx.conf redirect rule (excerpt)

  # FIXED: force-HTTPS keyed on X-Forwarded-Proto set by Cloudflare/cloudflared
  if ($http_x_forwarded_proto != "https") { return 301 https://$host$request_uri; }

Security note (general knowledge): X-Forwarded-Proto is a request header a client can set. Trusting it is reasonable here because the origin has no published ports and is only reachable through cloudflared. If your origin is also reachable directly, do not rely on this header alone.

(The test tunnel, DNS record and SSL rule were deleted after testing.)


What We Did Not Test

  • The nginx fix with SSL mode still set to Flexible: the control test switched the rule to Full first, so every “fixed” result above (including the second-machine re-test) was captured with SSL mode = Full. We did not re-test the nginx fix with Flexible.

  • Flexible SSL mode redirect loops without Cloudflare Tunnel: Standard DNS-proxied origins where Cloudflare connects directly to an origin on port 80/443 (without cloudflared) were not tested here.

  • Browser / mobile client checks: Checks from a desktop web browser or a mobile device, and Cloudflare dashboard screenshots of the SSL mode setting were not captured; all tests used curl over the public Internet.

  • Repeated 502 toggle: The Part A localhost -> nginx ingress fix was applied and verified once rather than toggled across multiple rounds.