Runbook 02 · Cloudflare Tunnel + Access

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
OutputMeaningNext step
2026.10.xClient inside the reviewed window.Continue to check 2.
2026.9.x or olderOlder 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 / DegradedNo 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.
HealthyFour 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/
OutputMeaningNext step
HTTP/1.1 200 OKThe local vhost answers for that Host. The route target is plausible; the problem is elsewhere.Continue to check 3.
200 OKA 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 / 308The 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 refusedNothing 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/"
OutputMeaningNext step
200 + cf-cache-status: HITA 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.
502Either 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.comAccess 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.
526The 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 = 5The 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".

01 — The route publishes nginx's default page
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.
02 — 502 with "Unable to reach the origin service"
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 cloudflared
That 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.
The remaining five, and the migration, are in the paid bundle
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 / componentWhat breaksWhat the runbook has you change
Human browserGets the default page, a 502, or a stale cached copyRoute target and Host header, then a purge scoped to the hostname
Agent / scriptFollows an Access redirect it can never complete, or gets a 401 from the policyService token headers plus a policy that authorizes them for that hostname
Streaming chat clientTokens arrive in one burst after the answer finishedOrigin labels the response as an event stream; proxy buffering checked end to end
Webhook receiverProvider cannot complete the Access login flowPath-scoped policy and a route that keeps the provider's Host intact
Deploy jobReports success while the public hostname still serves the previous buildCache invalidation as part of the deploy, with the purge scope recorded
Cache / purge operatorSingle-file purge silently does nothingPurge 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

Verification status
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.
For agents

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.

Request one
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.