Cloudflare Mesh, WARP Connector, and the Routes Nobody Tells You About
The Setup
The goal was a single Cloudflare Worker that calls an internal HTTP service on a private LAN. The path looks like this:
Worker → Cloudflare Mesh → WARP Connector (Ubuntu VM with CloudflareWARP for the tunnel and eth0 on the LAN) → internal service at a private IP such as 10.0.1.50:3000.
The binding in wrangler.jsonc:
"vpc_networks": [
{ "binding": "MESH", "network_id": "cf1:network", "remote": true }
]
cf1:network is the magic literal that says “use the account-wide Cloudflare Mesh network.” No tunnel UUID needed; any Mesh node or advertised subnet route is reachable.
The dashboard reported the tunnel as HEALTHY and the route covering the LAN as advertised. On paper, perfect.
The Symptoms
curl https://<worker>.workers.dev/healthreturns200 ok(this path doesn’t touch Mesh).curl https://<worker>.workers.dev/<any route that calls env.MESH.fetch>returns500.wrangler tailshows:handler failed: Error: handshake timeout.
The phrase handshake timeout is what sent me down a dead-end rabbit hole. It reads like an application-level handshake problem — TLS, a database protocol, something the upstream service speaks. I spent a good while debugging the upstream.
Dead End: It Must Be the Upstream
I added logging around env.MESH.fetch(...), confirmed the Worker was sending the right payload, and confirmed the request shape matched the upstream’s API. Then I hit the upstream directly from a machine with VPN access to the LAN:
curl http://10.0.1.50:3000/some-endpoint
# → 200 OK, correct response
Upstream works. But every call through the Worker still returned handshake timeout, and crucially, the upstream’s access log showed nothing when the Worker called it. No request was arriving.
That is the flip. The handshake timeout is not the upstream’s handshake timeout; it is Cloudflare Mesh’s. The connector never gets the packet through. The upstream never sees anything. Everything I had been debugging was the wrong layer.
warp-cli does not configure the OS for routing Worker-initiated traffic. At all. The connector ↔ Cloudflare session is healthy. The connector's ability to forward what arrives over that session is not.
What WARP Connector Actually Does (and Doesn’t)
When you install WARP Connector and bring it up with warp-cli, three things happen that you can see:
- The
CloudflareWARPtun interface is created. - nftables rules are installed in the
inet cloudflare-warptable, almost entirely for the connector’s own outbound traffic to Cloudflare’s edge. - A large routing table is installed in a side table (e.g. table
65743) to push internet-bound traffic through the tunnel for “warp client” usage.
What it does not do, and what is absolutely required for a Worker to reach anything via this connector:
- Set up NAT/MASQUERADE or otherwise handle the return path for traffic forwarded through the box.
- Install kernel routes for Cloudflare’s source-IP CGNAT range (
100.64.0.0/12) in the main routing table.
Both are prerequisites for a Worker request to make a round trip. Neither is surfaced in the WARP Connector setup flow. The dashboard happily reports the tunnel as Healthy the entire time, because the connector ↔ Cloudflare session genuinely is healthy.
Root Cause #1: The Return Path
Time to watch packets. On the connector:
tcpdump -i any -n host 10.0.1.50
While I fired a Worker request:
CloudflareWARP In IP 100.64.12.189.9025 > 10.0.1.50.3000: [S], seq 710527279
eth0 Out IP 100.64.12.189.9025 > 10.0.1.50.3000: [S], seq 710527279
eth0 In IP 10.0.1.50.3000 > 100.64.12.189.9025: [S.], seq 830383984, ack 710527280
eth0 Out IP 10.0.1.50.3000 > 100.64.12.189.9025: [S.], seq 830383984, ack 710527280
CloudflareWARP In IP 100.64.12.189.9025 > 10.0.1.50.3000: [S], seq 710527279 # retry
Reading the trace:
- CloudflareWARP In — SYN from the Worker, source
100.64.12.189(a Cloudflare CGNAT IP), destined to the upstream service. - eth0 Out — SYN forwarded onto the LAN, same source IP. Forwarding works.
- eth0 In — Upstream’s SYN-ACK, addressed back to
100.64.12.189. - eth0 Out — Connector tries to send the reply… out
eth0again. ❌
The reply destined for 100.64.12.189 doesn’t know about the CloudflareWARP tunnel, so the kernel falls back to its default route (the LAN gateway). The packet hits the LAN router, which has no idea what 100.64.x.x is, and drops it. The Worker never sees the SYN-ACK, retries a few times, then Cloudflare’s Mesh session times out with handshake timeout.
Why
Worker source addresses on this path live inside Cloudflare’s documented reserved IP ranges:
| Purpose | Default CIDR |
|---|---|
| Cloudflare source IPs (this is where Workers come from) | 100.64.0.0/12 |
| Gateway initial resolved IPs | 100.80.0.0/16 |
| Device IPs (Mesh nodes/clients) | 100.96.0.0/12 |
| Private Load Balancer IPs | 100.112.0.0/16 |
The connector’s own tun address is in 100.96.0.0/12. WARP installs routes only for what it needs as a member of the mesh — the /12 covering device IPs. It does not install anything for 100.64.0.0/12, where Worker traffic originates. So return packets to 100.64.x.x have nowhere to go.
Temporary Fix: MASQUERADE
The fastest unblock is NAT — rewrite the Worker’s source IP to the connector’s LAN IP on the way out, so the upstream replies to the connector itself:
nft add table inet nat
nft 'add chain inet nat postrouting { type nat hook postrouting priority srcnat ; policy accept ; }'
nft add rule inet nat postrouting oifname "eth0" ip saddr 100.64.0.0/10 masquerade
This worked. Worker requests round-tripped end-to-end. But NAT is an ugly compromise: the upstream (and anything behind it) sees the connector’s IP instead of the real Mesh peer. You lose observability and ACL granularity.
Root Cause #2: Just Give It a Real Route
With NAT working, the cleaner fix was obvious: hand the connector a real kernel route for Cloudflare’s CGNAT range through the tunnel.
ip route add 100.64.0.0/10 dev CloudflareWARP
Retest without NAT — also works, and now the upstream sees the real 100.64.x.x source. That is the correct architecture, but adding kernel routes by hand is brittle. They don’t persist across reboots, and scripting them feels gross.
Doing It Right: Redistribute via BGP
The connector’s upstream gateway already ran BGP for a mesh of site routers and overlay nodes in this network. The clean answer:
- The connector owns two static routes via
CloudflareWARP:100.64.0.0/12(Cloudflare source IPs) and100.96.0.0/12(Mesh device IPs). - The connector advertises both /12s into BGP.
- The upstream router installs them, and the rest of the network knows “traffic to these Cloudflare CGNAT blocks goes via the connector.”
FRR handles this cleanly. staticd owns the routes (no manual ip route add), bgpd redistributes them with a prefix-list filter so nothing else can ever leak out.
The Tailscale Overlap Question
If you also run Tailscale, its tailnet lives in 100.64.0.0/10 (Tailscale’s default CGNAT). In practice a typical tailnet uses a small sliver of that — often 100.64.0.0/24 with each node advertised as a /32. Because BGP uses longest-prefix match:
100.64.0.5(a Tailscale node) matches a/32learned via the Tailscale router → goes to Tailscale. ✓100.64.12.189(a Worker) matches only the connector’s/12→ goes to Cloudflare. ✓
As long as Tailscale stays inside its assigned range, longest-prefix match resolves the overlap with no special config. Zero conflict.
Sample FRR Config
/etc/frr/frr.conf on the connector:
frr version 10.5.1
frr defaults traditional
hostname cloudflared
log syslog informational
service integrated-vtysh-config
!
ip route 100.64.0.0/12 CloudflareWARP
ip route 100.96.0.0/12 CloudflareWARP
!
ip prefix-list CF-MESH seq 10 permit 100.64.0.0/12
ip prefix-list CF-MESH seq 20 permit 100.96.0.0/12
!
route-map CF-MESH-OUT permit 10
match ip address prefix-list CF-MESH
exit
!
route-map ALLOW-ALL permit 10
exit
!
router bgp <YOUR_ASN>
neighbor <PEER_IP> remote-as external
!
address-family ipv4 unicast
redistribute static route-map CF-MESH-OUT
neighbor <PEER_IP> activate
neighbor <PEER_IP> route-map ALLOW-ALL in
neighbor <PEER_IP> route-map CF-MESH-OUT out
exit-address-family
exit
!
Notes for anyone replicating:
staticdis enabled by default on FRR 10.x even though it is not listed in/etc/frr/daemons. The comment at the top of that file says watchfrr, zebra, and staticd are always started.- The
ip routelines at the top are FRR static routes (staticd), not kernel-direct — but staticd installs them into the kernel, so they appear inip routeoutput asproto static metric 20withCloudflareWARPas the next-hop. - The
redistribute static route-map CF-MESH-OUTcombo is belt-and-braces. staticd only has those two routes anyway, but the filter guarantees nothing leaks if static routes are added later. - FRR 7+ defaults to RFC 8212 behaviour: an eBGP peer without inbound policy gets zero learned routes. Hence the
route-map ALLOW-ALL in. Replace with a real prefix-list if you need inbound filtering.
Verify
# FRR's view
vtysh -c "show ip route static"
vtysh -c "show ip bgp summary"
vtysh -c "show ip bgp neighbor <PEER_IP> advertised-routes"
# Kernel's view
ip -4 route | grep -E "100\.(64|96)\.0\.0"
# nft (should be only the WARP-managed table, no nat)
nft list ruleset | grep -E "table|masquerade" | head
Tear Down the Temporary NAT
Once BGP is advertising the routes and the upstream has accepted them:
nft delete table inet nat
Re-run a Worker call. The real Worker source IP should appear on the upstream side, and everything still works.
Summary: The Two Hidden Steps
If you are deploying a Cloudflare Worker → Cloudflare Mesh → WARP Connector → private network setup, after installing and connecting warp-cli, you also need to:
- Install a route for the Cloudflare source IP range (
100.64.0.0/12) pointing at theCloudflareWARPinterface. Optionally also100.96.0.0/12for device IPs, if you want other Mesh devices to reach LAN services through this connector. - Provide a return path for that range — either by NAT/masquerade (simple, lossy for observability) or by advertising the range into your internal routing protocol (clean, preserves source IPs).
Neither is surfaced in the WARP Connector setup docs. Both are prerequisites. The dashboard shows Healthy regardless of whether they are configured. The error surface, to a Workers developer, is a near-opaque Error: handshake timeout.
tcpdump in one window and nft in another, trying to figure out why your reply packets are going out the wrong interface.
At the time of writing, Mesh-for-Workers is in public beta and these steps are missing from the WARP Connector install flow. Hopefully they get bundled in before GA. This post documents what was needed to make it work in that beta state.