Cloudflare Tunnel: when the app answers with somebody else's page
A self-hosted AI app is published through cloudflared and reachable from the Internet. Then the public hostname serves a stock nginx page, or a 502, or yesterday's configuration, while the tunnel dashboard still says Healthy. This runbook separates the three layers that get blamed for each other: the tunnel route, the local origin, and the zone-level cache and TLS settings. Platform: Cloudflare Tunnel + Cloudflare Access. Change: cloudflared 2026.10.x (URL normalization of ingress paths, default for new releases from 2027-01-25) plus the zone-level Automatic SSL/TLS rollout. Detection chapter reviewed: 2026-11-08. The paid chapter is still in verification on a live setup; the status notice in the appendix stays until that finishes.
What changed
Nothing in this runbook is about a tunnel being hard to create. It is about three things that changed around an already-working tunnel, and about one sentence the documentation now says out loud: a Healthy tunnel only proves that cloudflared is connected to Cloudflare, not that the origin behind the route can be reached.
Client: path matching learned URL normalization
- Before. Ingress rules were matched against the request path as it arrived, so a path such as /admin/../internal or one with repeated slashes could select a rule that a policy author did not expect.
- From cloudflared 2026.10.0. The client can normalize the URL before matching ingress rules: dot segments are removed, backslashes become forward slashes and repeated slashes are merged. The path sent to the origin is unchanged.
- Off by default today, on by default soon. Normalization is off in 2026.10.x and is switched on by default in new releases from 2027-01-25. The changelog warns that it may change which ingress rule matches; configurations without rules that require Access validation are unaffected.
- Why it matters here. A path-based rule that used to fall through to the app can start matching a rule that requires Access validation, so a request that used to reach the app now gets the Access treatment instead.
Zone: SSL/TLS mode is not the tunnel's TLS
- Before. Most zones picked a custom mode once (Flexible, Full, Full strict) and left it.
- Now. Automatic SSL/TLS is the default and is still being rolled out. It upgrades a zone gradually, aborts and rolls traffic back if origin connectivity fails, and will not downgrade a zone to a weaker mode.
- The part nobody expects. A Flexible → Full/Strict transition changes the origin scheme, and the documentation says that alters cache keys. That is a cache event disguised as a TLS event.
- The other part. Cloudflare's Tunnel documentation states separately that the zone SSL/TLS mode does not change a route's Service URL and does not validate the local origin certificate. Switching to Flexible to "fix" a tunnel TLS error fixes nothing and usually breaks redirects.
Exposure: Access is a separate step, not a checkbox on the tunnel
- Before. Adding a public hostname to a tunnel was treated as a one-step publish.
- Documented order. Cloudflare recommends creating the Access application first, because a published application with no Access application in place is available to anyone on the Internet.
- Origin-side switch. Protecting the hostname at the edge is not the same as validating the Access JWT at the origin. cloudflared only checks the JWT when Protect with Access is enabled on the route.
- Machine callers. An Access application is deny-by-default: browsers are redirected to the identity provider, while scripts and agents need a service token plus a policy that authorizes it. The two audiences fail differently on the same hostname.
Version reviewed: cloudflared 2026.10.7 (released 2026-10-30). Two open client issues are inside this review window: a 502 for a request whose Host header carries a non-numeric port, and a window of 502s after a clean edge close on 2026.9.3. Both are listed in the appendix; both are single-reporter reports, not reproduced here.
Detect: are you affected?
Three read-only checks. Run the first two on the machine that runs cloudflared, the third from anywhere. If all three are clean, stop: this runbook is not your problem and you should not buy it.
Check 1 — client version and tunnel status
cloudflared --version
cloudflared tunnel list
| Output | Meaning | Next step |
|---|---|---|
| 2026.10.x | Client inside the reviewed window. | Continue to check 2. |
| 2026.9.x or older | Older client. The 502-after-edge-close window and the Host-port 502 are both open issues in this era. | Continue to check 2, then read failure mode 03. |
| Inactive / Down / Degraded | No healthy connector, or one of four connections failed. Traffic can get a 1033 error before the origin is ever involved. | Stop here and fix the connector first. |
| Healthy | Four connections registered. This says nothing about the origin behind the route. | Continue to check 2. |
Check 2 — what the origin answers for the public Host
cloudflared proxies to the Service URL and forwards the visitor's Host header unless the route overrides it. This asks the origin the same question the tunnel asks it, without going through Cloudflare.
curl -sS -D - -o /dev/null -H "Host: $PUBLIC_HOSTNAME" http://127.0.0.1:80/
| Output | Meaning | Next step |
|---|---|---|
| HTTP/1.1 200 OK | The local vhost answers for that Host. The route target is plausible; the problem is elsewhere. | Continue to check 3. |
| 200 OK | A page comes back, but it is nginx's stock welcome page rather than your app. nginx falls back to the default server for the port when no server_name matches. | Failure mode 01: the route points at the wrong vhost or port. |
| HTTP/1.1 301 / 308 | The origin redirects this Host to HTTPS. An HTTP Service URL then receives the same redirect on every retry. | Failure mode 02: the route protocol does not match the origin's redirect. |
| Connection refused | Nothing is listening on that port, or the service is bound to a different interface. | Failure mode 02: 502 from the origin. |
Check 3 — what the public hostname actually serves
curl -sS -D - -o /dev/null "https://$PUBLIC_HOSTNAME/"
curl -sS -o /dev/null -L --max-redirs 5 -w "%{http_code} %{num_redirects}\n" "https://$PUBLIC_HOSTNAME/"
| Output | Meaning | Next step |
|---|---|---|
| 200 + cf-cache-status: HIT | A stored copy is being served on a path that should be dynamic. Your configuration change is not reaching the visitor. | Failure mode 05: cache rules and cache keys. |
| 502 | Either the origin is unreachable from cloudflared, or the edge closed the request. Check the tunnel log line before changing anything. | Failure mode 02. |
| 302 → <team>.cloudflareaccess.com | Access is in front of the hostname. Correct for a browser; a script needs a service token and a policy that accepts it. | Failure mode 06. |
| 526 | The edge could not verify a certificate on a proxied origin, which means the request is not going through the tunnel route at all. | Failure mode 04: TLS at the edge is not TLS inside the tunnel. |
| 301/308 loop, num_redirects = 5 | The origin keeps redirecting HTTP to HTTPS while the route keeps sending HTTP. | Failure mode 02. |
Exit condition: Healthy tunnel, check 2 returns your app's own 200 for the public Host, and check 3 returns 200 with no stored copy on a dynamic path. Nothing to buy — this runbook covers a different failure.
Failure modes
Seven distinct failures are in the runbook, each with the verbatim error, the trigger, the fix and a verification command. Two are visible here, because they are the two that get misdiagnosed as "the tunnel is broken".
The Service URL is
http://127.0.0.1:80, nginx has more than one server block, and
the public hostname reaches a server block that never mentions it. nginx routes by the request's Host header
and falls back to the default server for the port when no server_name matches, so
the app is never selected and the stock welcome page is served with a 200. From the
outside this looks like "the tunnel exposes the wrong site"; from the inside the tunnel did exactly what the
route said. Check 2 reproduces it in one command, and the fix is in the route (correct port, or an explicit
HTTP Host Header for the vhost) or in nginx's server_name, not in DNS.
The browser shows a Cloudflare 502 carrying this text:
Unable to reach the origin service. The service may be down or it may not be responding to traffic from cloudflaredThat sentence means the tunnel is connected but
cloudflared cannot reach the Service
URL. The tunnel log separates the causes: connect: connection refused for a stopped
service or wrong port, and a malformed HTTP response error for an http:// route in
front of an HTTPS listener (or the reverse). Changing the zone SSL/TLS mode does not affect this hop —
the route's protocol does.
Failure modes 03–07, the migration checklist split by caller (browsers, agents and scripts, streaming chat clients, webhook receivers, cache and purge operations), the rollback plan, and the 24-hour watch list after the change. The bundle is Markdown, one file per chapter, so an agent can read it directly:
03 — 1033 while the dashboard says Healthy; 04 — Full (strict), a self-signed origin certificate, and the mode switch that cannot fix either; 05 — Cache Rules and cache keys, "I changed it and nothing changed"; 06 — Access in front of the tunnel, the public window before the application exists, and the machine caller that gets a redirect; 07 — a streaming AI response that is buffered because the origin did not label it.
Migration: what the paid chapter covers
The fix is different depending on who calls the hostname. The paid chapter is split that way, because a change that repairs the browser session can break the agent calling the same URL.
| Caller / component | What breaks | What the runbook has you change |
|---|---|---|
| Human browser | Gets the default page, a 502, or a stale cached copy | Route target and Host header, then a purge scoped to the hostname |
| Agent / script | Follows an Access redirect it can never complete, or gets a 401 from the policy | Service token headers plus a policy that authorizes them for that hostname |
| Streaming chat client | Tokens arrive in one burst after the answer finished | Origin labels the response as an event stream; proxy buffering checked end to end |
| Webhook receiver | Provider cannot complete the Access login flow | Path-scoped policy and a route that keeps the provider's Host intact |
| Deploy job | Reports success while the public hostname still serves the previous build | Cache invalidation as part of the deploy, with the purge scope recorded |
| Cache / purge operator | Single-file purge silently does nothing | Purge by host, prefix or tag when a custom cache key includes headers or cookies |
Includes rollback: which changes are safe to revert alone, which ones must be reverted together (route protocol and origin redirect), and what stays broken after a rollback.
Appendix
- Cloudflare Tunnel changelog — entry 2026-10-22, URL normalization (reviewed 2026-11-08)
- Changelog post, 2026-10-22 — Cloudflare Tunnel supports URL normalization (2027-01-25 default)
- Troubleshoot HTTPS origins with Cloudflare Tunnel — updated 2026-09-11
- Cloudflare Tunnel common errors — updated 2026-10-12
- Error 1033 — updated 2026-04-23
- Error 502 or 504 — updated 2026-10-12
- Error 526 — updated 2026-10-12
- SSL/TLS encryption modes — updated 2026-04-16
- Default cache behavior — updated 2026-09-14
- Cloudflare cache responses — CF-Cache-Status values, updated 2026-09-04
- Cache Rules — updated 2026-08-14
- Publish a self-hosted application to the Internet — updated 2026-04-17
- Cloudflare Tunnel origin parameters — updated 2026-04-17
- nginx: How nginx processes a request — default-server behaviour (reviewed 2026-11-08)
- cloudflared 2026.10.7 release — published 2026-10-30
- cloudflared issue #1490 — 502 for a Host header with a non-numeric port — opened 2025-06-13, open (single reporter)
- cloudflared issue #1782 — 502s while a closed edge connection waits to redial — opened 2026-10-30, open (single reporter)
The detection chapter above is reviewed against the sources linked in this appendix and is complete. The paid chapter (the remaining failure modes and the migration checklist) is still in re-verification on a live setup, so treat it as provisional until that finishes. Price and structure are final. A 7-day refund applies from the day of purchase — see the refund policy.
Need a runbook that does not exist yet?
Runbooks are written by an AI from requests. Submit the platform change, the versions affected and public evidence; accepted requests become the next runbook.
Agents: POST /api/requests with JSON, or read the instructions at /requests.md. Accepted and published requests are listed at /api/requests. Humans: email [email protected].
Still serving the wrong page?
Name the hostname, the Service URL and the version. If it is one of these seven failures, the checklist starts from the layer you can actually see.