Field Report / Cloudflare Mesh

Cloudflare Mesh, WARP Connector, and the Routes Nobody Tells You About

April 2026 cloudflareworkerswarp-connectorfrrbgpnetworking
Cloudflare Mesh promises a developer-friendly path from a Worker to a private network: install WARP Connector, advertise a subnet route, add a binding to wrangler.jsonc, and call env.MESH.fetch(). The dashboard reports the tunnel as Healthy and you get on with your day. In practice, getting a single Worker request to round-trip through that connector took a couple of hours, three distinct root causes, and rebuilding the connector's routing path with FRR. None of it is documented. This is what was actually missing.

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/health returns 200 ok (this path doesn’t touch Mesh).
  • curl https://<worker>.workers.dev/<any route that calls env.MESH.fetch> returns 500.
  • wrangler tail shows: 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.

The load-bearing fact
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:

  1. The CloudflareWARP tun interface is created.
  2. nftables rules are installed in the inet cloudflare-warp table, almost entirely for the connector’s own outbound traffic to Cloudflare’s edge.
  3. 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:

  1. CloudflareWARP In — SYN from the Worker, source 100.64.12.189 (a Cloudflare CGNAT IP), destined to the upstream service.
  2. eth0 Out — SYN forwarded onto the LAN, same source IP. Forwarding works.
  3. eth0 In — Upstream’s SYN-ACK, addressed back to 100.64.12.189.
  4. eth0 Out — Connector tries to send the reply… out eth0 again. ❌

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:

PurposeDefault CIDR
Cloudflare source IPs (this is where Workers come from)100.64.0.0/12
Gateway initial resolved IPs100.80.0.0/16
Device IPs (Mesh nodes/clients)100.96.0.0/12
Private Load Balancer IPs100.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:

  1. The connector owns two static routes via CloudflareWARP: 100.64.0.0/12 (Cloudflare source IPs) and 100.96.0.0/12 (Mesh device IPs).
  2. The connector advertises both /12s into BGP.
  3. 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 /32 learned 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:

  • staticd is 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 route lines at the top are FRR static routes (staticd), not kernel-direct — but staticd installs them into the kernel, so they appear in ip route output as proto static metric 20 with CloudflareWARP as the next-hop.
  • The redistribute static route-map CF-MESH-OUT combo 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.

2undocumented prerequisites
3root causes to untangle
1opaque error: handshake timeout

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:

  1. Install a route for the Cloudflare source IP range (100.64.0.0/12) pointing at the CloudflareWARP interface. Optionally also 100.96.0.0/12 for device IPs, if you want other Mesh devices to reach LAN services through this connector.
  2. 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.

The developer-experience gap
For a beta product this is a pretty large gap, especially because the user who hits this pain is usually not a network engineer. Workers is a developer product. Mesh is pitched as an easy way to reach private resources. A developer expects to deploy a Worker and have it work. Instead you end up with 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.