The report was one line: "NetworkError when attempting to fetch resource — can't log in." No stack trace, no status code, no error body. Here's the trace from that to a confirmed root cause, the permanent fix, and the two things that made it trickier than it should have been. It's the same single-box service from my Cloudflare Tunnel and deploy pipeline posts.
Step 1: read what the error is actually telling you
NetworkError (Firefox) / Failed to fetch (Chrome) is not an application
error. A wrong password returns a 401 with a JSON body — the request
completes. NetworkError means the request never got a valid HTTP response at
all: CORS, DNS, TLS, mixed content, or the origin being unreachable.
So before touching any code, I ruled the layers out from the outside in. The frontend was fine — right endpoint, env set. The decisive check was hitting the API directly:
$ curl -s -o /dev/null -w '%{http_code}' https://api.example.com/up
530
HTTP 530. That's not from the app — it's generated at the Cloudflare edge, and it means Cloudflare couldn't reach the origin. The body named the exact sub-code:
error code: 1033
Step 2: 1033 means the tunnel, not the app
Cloudflare error 1033 is an Argo Tunnel error: the cloudflared connector
isn't connected to the edge. The 530 is served before routing, which is why it
hit every path identically — /up and /api/v1/auth/login both 530'd. That's
also why the symptom was NetworkError and not a clean error page: a 530 carries
no CORS headers, so a cross-origin fetch just fails.
Quick triage said the host itself was fine — the instance was running, its static frontend served 200. So this was specifically the connector. On the box, the container logs told the real story:
ERR failed to accept incoming stream requests
error="failed to accept QUIC stream: timeout: no recent network activity"
...
INF Registered tunnel connection ... protocol=quic
The tunnel used QUIC (UDP) as its transport. On this network path QUIC periodically stalled — "no recent network activity" — dropped all four edge connections, returned 1033 for a window, then reconnected ~18 seconds later. The container never crashed. Memory was fine (1.7 GB free), disk was fine (25%), no OOM. It was purely the transport flapping.
Step 3: the fix is one flag
cloudflared can run over HTTP/2 (TCP) instead of QUIC. HTTP/2 doesn't have the UDP-path failure mode that was biting here:
tunnel --no-autoupdate --protocol http2 run
After it took effect, the logs flipped:
INF Environment is healthy. cloudflared will use 'http2' as primary protocol.
INF Registered tunnel connection ... protocol=http2
You trade a little throughput for connections that don't silently time out. For an API behind a tunnel, that's the right trade every time.
The gotcha that ate an hour: kamal deploy doesn't touch accessories
Here's the part worth internalizing. I committed the flag, pushed to main, the
deploy pipeline ran green — and the tunnel was still on QUIC.
Because a routine kamal deploy only redeploys the application containers.
The tunnel connector runs as an accessory, and accessory config changes don't
apply on a normal deploy. They need an explicit:
kamal accessory reboot cloudflared
A green deploy told me the fix shipped. Verification told me it hadn't. Trust the verification, not the deploy status — I only caught it because I checked the running container's actual args instead of assuming:
$ docker inspect cloudflared --format '{{join .Args " "}}'
# still the old QUIC args, despite a successful deploy
Making it self-heal, cheaply
--protocol http2 removes the cause, but I wanted the category — "tunnel up but
not serving" — to recover on its own. Three layers, no new infrastructure and no
added cost:
- cloudflared already reconnects dropped edge connections by itself.
- The container runs with
--restart unless-stopped, so a crash comes back. - For the wedged-but-alive case, a tiny watchdog: cloudflared exposes
/readyon a metrics port (200 when ≥1 connection is registered). Asystemdservice polls it and restarts the connector only after several consecutive failures — so a normal ~20-second reconnect never trips it. The watchdog is a bash loop hitting127.0.0.1; it costs nothing andsystemdkeeps the watchdog itself alive.
The threshold matters more than the mechanism: restart too eagerly and your "auto-heal" turns a 20-second blip into a restart storm. Count consecutive failures, and give it time to reconnect before checking again.
What I'd tell past me
- Read the error's category first.
NetworkError≠ auth failure ≠ 500. Ruling out "did the request even get a response?" pointed straight at the edge and saved chasing the app. - A 5xx from the edge is not your app. Learn your CDN's error codes — 1033 named the failing component before I'd read a single line of application code.
- Verify the running state, not the deploy result. "Deploy succeeded" and "the change is live" are different claims. Inspect the actual process args.
- Auto-heal the category, not the incident — and tune the trigger so the cure isn't worse than the disease.
The whole thing came down to one flag and one watchdog script. Most of the time
was spent not guessing — which, for a symptom as vague as NetworkError, is
the only way it ever goes fast.