Cloudflare Tunnel, End to End: How a Laptop Under My Desk Serves the Internet Without a Single Open Port
An old gaming laptop sits under my desk with the lid closed. It runs a pile of side projects, including the auth service at auth.kaplabs.dev. My ISP gives me no static IP, my router has zero port forwards, and nobody on the internet can open a TCP connection to that machine.
And yet auth.kaplabs.dev loads in a browser anywhere in the world, with a valid certificate, behind DDoS protection.
The trick is Cloudflare Tunnel: the laptop never accepts a connection, it makes one. This post covers how that works, all the way down: the protocol, the routing, high availability, Zero Trust, how to debug it when it breaks, what else you could use, and where it falls short.
The one idea: flip the direction of the connection
Every home network is built to let connections out and keep connections in. Your router does NAT: it rewrites outgoing packets to its own public IP and remembers the mapping so replies find their way back. An unsolicited inbound packet has no mapping, so the router drops it. That is why hosting at home has traditionally meant a port forward (a static mapping you add by hand).
Port forwarding has four problems:
- You need a public IPv4 to forward. More and more ISPs put customers behind CGNAT (carrier-grade NAT), where the public IP belongs to the ISP and is shared with hundreds of neighbours. You cannot forward a port on a router you don't own.
- Your home IP is in public DNS. Anyone can find it, scan it, or DDoS it.
- Every scanner on the internet can reach your app and hammer your login page.
- Dynamic IPs mean you need DDNS on top.
Cloudflare Tunnel flips it around. A small daemon, cloudflared, runs next to your app and dials out to Cloudflare, the same way a browser dials out to a website. That outbound connection passes through NAT and CGNAT without trouble. Then, when a visitor's request arrives at Cloudflare, it gets sent back down the connection your machine already opened.
Step through both models. Toggle between them and watch where the port-forward version dies:
The router in the tunnel model never sees an unsolicited inbound packet. From its point of view, the laptop just has a long-running outbound connection to a Cloudflare IP on port 7844, like any long-poll or video call.
This is the same pattern as an SSH reverse tunnel (ssh -R), ngrok, or a reverse proxy whose upstream connection is turned around. What Cloudflare adds is that the far end of the tunnel is a global anycast network with TLS, a WAF, caching and identity built in.
Architecture: the whole map
There are four moving parts:
| Component | Where it runs | Job |
|---|---|---|
| cloudflared | Your machine (binary, Docker, k8s Deployment) | Opens and keeps outbound connections, holds the ingress rules, proxies each request to a local service |
| Edge PoP | Every Cloudflare data center | Terminates visitor TLS, runs WAF/DDoS/Access, works out which data center holds the tunnel's connections, forwards there |
| Control plane | Cloudflare API + dashboard | Tunnel identity (a UUID plus a secret), DNS records, ingress config for remotely managed tunnels, Access policies |
| DNS | Cloudflare authoritative DNS | app.example.com is a proxied CNAME to <tunnel-uuid>.cfargotunnel.com |
What the DNS record really is
When you run cloudflared tunnel route dns my-tunnel app.example.com, Cloudflare creates:
The cfargotunnel.com name doesn't resolve to any IP of yours. It works as a pointer inside Cloudflare's system: "requests for this hostname belong to tunnel 6ff42ae2...". Because the record is proxied, public resolvers only ever see Cloudflare anycast IPs (see DNS if CNAME chains are fuzzy). That's also why the hostname must be in a zone on Cloudflare, or routed through a load balancer pool that points at the tunnel.
Anycast gets the visitor to the nearest edge
Cloudflare announces the same IP prefixes from every data center using BGP anycast. A visitor in Mumbai and one in Berlin resolve the same IP and land on different machines, each the nearest by BGP path. TLS terminates there (see TLS), which is why the TLS handshake is fast even if your laptop is 12,000 km away.
The edge has to find your tunnel
The PoP that receives the request is usually not the PoP your cloudflared connected to. The laptop in San Jose holds connections to, say, SJC and LAX. A visitor hits Frankfurt. Frankfurt has to:
- Map hostname to tunnel UUID (from the DNS/config).
- Look up which data centers currently hold live connections for that UUID. cloudflared registers each connection on startup, so the edge keeps a distributed, constantly updated registry of tunnel connections.
- Forward the request over Cloudflare's backbone to one of those data centers (with Argo Smart Routing turned on, it uses the fastest measured path rather than the default one).
- That data center pushes the request into the matching connection as a new stream.
"Healthy" in the dashboard only describes the cloudflared to Cloudflare connections. A tunnel can be perfectly Healthy while every request 502s because the app behind it is down. Monitor the app, not just the tunnel.
Inside the connection: QUIC, streams and registration
Each cloudflared process opens four connections, to four different servers spread across at least two data centers. They go out on port 7844:
protocol: quic(the default viaauto) runs QUIC over UDP 7844.protocol: http2runs HTTP/2 over TLS over TCP 7844 and is the fallback when UDP is blocked.
Each connection is long-lived and multiplexed:
When a connection comes up, cloudflared authenticates with the tunnel's secret (from the credentials file or token) and calls a registration RPC over a control stream. From then on, every visitor request is a new stream on that connection: an HTTP request, a WebSocket, a TCP proxy session for cloudflared access, even a UDP flow for private-network traffic.
Why QUIC beat the older transports (the original Argo Tunnel used a custom multiplexer called h2mux, then HTTP/2):
- No head-of-line blocking across streams. With HTTP/2 over TCP, one lost packet stalls every stream in the connection until it's retransmitted. QUIC does loss recovery per stream, so a lost packet on someone's image download doesn't freeze someone else's API call. See HTTP/1.1 and HTTP/2 and UDP.
- It can carry UDP and ICMP natively (QUIC datagrams), which private networking needs for DNS, VoIP and
pingthrough the tunnel. HTTP/2 can only carry bytes in order. - Faster reconnects, and TLS 1.3 is built in.
4 connections per cloudflared, at least 2 data centers, port 7844 (UDP for QUIC, TCP for HTTP/2). Each replica you add contributes another 4 connections.
Locally managed vs remotely managed
| Locally managed | Remotely managed (token) | |
|---|---|---|
| Created with | cloudflared tunnel create | Dashboard / API |
| Secret on disk | ~/.cloudflared/<UUID>.json credentials file | A token passed as --token / TUNNEL_TOKEN |
| Ingress rules live in | config.yml on the host | Cloudflare; pushed down the tunnel to cloudflared |
| Good for | GitOps, version-controlled config | Fleets, k8s, "change it from my phone" |
There's also cert.pem from cloudflared tunnel login: a management credential that lets the CLI create tunnels and DNS records for your account. You need it to manage tunnels, not to run one. A host that only runs a tunnel needs just the credentials file or token.
My setup: a gaming laptop as a homeserver
This is the part I can speak to from experience. The laptop runs everything in Docker; cloudflared runs as a systemd service with a locally managed tunnel:
Rules are matched top to bottom, first match wins, on hostname (wildcards allowed) and an optional path regex. The final rule must be a catch-all with no hostname or cloudflared refuses to start. Two commands save a lot of guessing:
Why this suits a laptop under a desk:
- No static IP, no port forwarding, CGNAT-proof. The tunnel doesn't care what my public IP is today.
- Services bind to
127.0.0.1. Docker publishes127.0.0.1:9000:9000, not0.0.0.0, so even someone on my LAN can't skip past Cloudflare. - Free edge TLS. I never run certbot; the edge certificate covers
*.kaplabs.dev. - The battery is a built-in UPS, and cloudflared reconnects on its own after Wi-Fi blips.
Laptop-specific gotchas I hit:
Closing the lid suspends the machine, which looks like a dead tunnel (error 1033). Set HandleLidSwitch=ignore in /etc/systemd/logind.conf. Also disable Wi-Fi power saving; a NIC that naps drops the QUIC connections, and the tunnel flips to Degraded and back.
If you delete and recreate a tunnel, the new one gets a new UUID, but your DNS CNAMEs still point at the old one. Result: 1033 on every hostname. Re-run cloudflared tunnel route dns (with -f to overwrite) for each hostname.
High availability: replicas, and what they are not
Run the same tunnel (same UUID and credentials) on a second machine and you have a replica. Each replica opens its own 4 connections. If the laptop dies, the Pi keeps serving.
Break things and watch what the edge does:
Click any dot to drop a single connection. Notice that replicas are not load-balanced: the edge sends traffic to the closest live connection, so one replica can take everything.
What to take from the simulator, which is what Cloudflare's docs say:
- Replicas are for failover, not load balancing. The edge sends a request to the geographically closest live connection. There's no round-robin and no weighting. If the closest replica fails, the edge retries others in no guaranteed order.
- For real traffic distribution, put a Cloudflare Load Balancer in front with each tunnel as a pool origin, so you get health checks, weights and steering (see load balancing).
- Deploys: roll replicas one at a time. cloudflared has a
--grace-periodso in-flight requests can finish on shutdown, and the edge stops picking a connection once it's unregistered. - In Kubernetes, run cloudflared as a Deployment with 2+ replicas and point ingress rules at Service DNS names (
http://api.default.svc:8080). Use the/readymetrics endpoint as the readiness probe.
Beyond public hostnames: Zero Trust
A public hostname is only one of the things a tunnel can carry. The same connector also underpins Cloudflare One (their SASE/Zero Trust platform):
Access in front of an app
Put a Cloudflare Access application on a hostname and the edge demands an identity (Google, GitHub, Okta, one-time PIN) before anything reaches the tunnel. That gets you SSO on an admin panel that has no auth of its own.
Two things to get right:
- Validate the JWT at the origin. Access adds a
Cf-Access-Jwt-Assertionheader. Without validation, anything that can reachlocalhost:9000(another container, a compromised neighbour process) bypasses Access. cloudflared can enforce it for you withoriginRequest.access: { required: true, teamName, audTag }. - Machines can't do browser SSO. Headless clients use service tokens (
CF-Access-Client-Id/CF-Access-Client-Secretheaders) instead.
Private networks (the VPN replacement)
Now users running the WARP client, enrolled in your Zero Trust org, can reach 10.0.x.x directly, for SSH, RDP, databases, anything TCP/UDP, with per-user identity and device-posture policy enforced by Gateway. Unlike a classic VPN, no concentrator is exposed to the internet, and access is decided per application, not "you're on the network, you can reach everything".
This is also where gRPC and arbitrary TCP belong. Public-hostname ingress is for HTTP(S), WebSockets and the cloudflared access client modes. Non-HTTP protocols for machine clients go over private routing.
Private routes must not overlap the WARP CGNAT range 100.96.0.0/12. If your home LAN or a Docker network uses 100.64.0.0/10-style addresses, pick something else.
Troubleshooting
The single most useful habit is to name the failing hop first. There are three segments, and every error message belongs to one of them:
- Visitor to Cloudflare edge: DNS, TLS certificate, WAF/bot rules, Access.
- Edge to cloudflared: the tunnel itself. Is a connector registered? Is 7844 reachable?
- cloudflared to your app: the ingress rule. Right port, right scheme, app up?
Pick a symptom:
Cloudflare has the hostname but cannot find a healthy cloudflared for that tunnel.
- cloudflared is not running (laptop asleep, service crashed, power loss)
- DNS CNAME points at an old tunnel UUID after you recreated the tunnel
- Outbound :7844 blocked so connections never registered
cloudflared tunnel info <name> systemctl status cloudflared dig app.example.com CNAME # compare UUID with `cloudflared tunnel list`
Fix: Start the connector, or re-run `cloudflared tunnel route dns <name> <host>` so the CNAME matches the live tunnel.
The runbook I actually use
Cloudflare-branded error page with 1033: the edge found no connector, so look at segment 2 (process, UUID, 7844). Plain 502 with "unable to reach the origin": the tunnel works, so look at segment 3 (port, http vs https, cert name). A TLS error before any Cloudflare page: segment 1 (edge certificate, multi-level subdomain). Intermittent drops on long sessions: UDP idle timers or maintenance, so add app keepalives or try protocol: http2.
Other failures worth knowing for an interview:
failed to sufficiently increase receive buffer size: quic-go wants a bigger UDP buffer. Usually harmless; on Linux raisenet.core.rmem_max(Cloudflare suggests 2500000).x509: certificate signed by unknown authority: the origin (or a corporate TLS-inspection proxy) uses a CA cloudflared doesn't trust. SetcaPool, notnoTLSVerify.too many open files: raiseulimit -n; each stream to the origin is a socket.- SSE / streaming responses arrive all at once: responses are buffered unless the origin sends
Content-Type: text/event-stream. websocket: bad handshakewithcloudflared access: the tunnel is down, WebSockets are disabled in the zone, SSL mode is Off, Bot Fight Mode is blocking, or a Worker route is swallowing the hostname.pingdoesn't work over a private route: ICMP rides a separate ICMP proxy in cloudflared; on Linux it needs permission to open ICMP sockets (net.ipv4.ping_group_range), or ping silently fails.
For the general version of this discipline (refused vs reset, which layer is lying), see the debugging by layer and dig and DNS debugging.
Pros and cons, honestly
Why it's great
- Zero inbound attack surface. No open ports, origin IP never published. You can't DDoS an IP you don't know, and you can't port-scan a machine that accepts nothing.
- Works behind anything that allows egress: CGNAT, double NAT, hotel Wi-Fi, a locked-down corporate VPC with no public subnet.
- The edge comes free: TLS certificates, HTTP/3 to visitors, DDoS mitigation, WAF, caching, Access, all configured as policy instead of servers.
- Identity-aware access turns "VPN into the network" into "this user, this device, this app".
- Free for most uses, including the Zero Trust tier for small teams.
- Operationally tiny: one static Go binary, outbound-only, no inbound firewall rules to review.
Where it hurts
- Cloudflare terminates your TLS. The edge decrypts every request. That is how the WAF and cache work, and it also means Cloudflare is a trusted man-in-the-middle by design. For end-to-end encryption you want something like Tailscale, a raw TCP tunnel, or your own VPS.
- Vendor lock-in and a shared fate. DNS, TLS, routing and auth all move into one provider. When Cloudflare has a bad day (the 18 November 2025 global outage, triggered by an oversized Bot Management feature file, is the one everyone remembers), every tunnel behind it has the same bad day.
- Your zone must be on Cloudflare DNS (or you use a partial setup / load balancer) for a public hostname.
- HTTP-first. Arbitrary TCP/UDP and gRPC need private routing plus WARP or
cloudflared accesson every client. That client footprint is the same cost as just joining a Tailscale tailnet. - Limits: proxied requests time out after roughly 100 seconds (524), the Free plan caps uploads at 100 MB, and quick tunnels allow 200 in-flight requests, no SSE and a random hostname.
- Content policy: serving lots of video or large binaries from a free plan runs into Cloudflare's service terms. Stream and R2 exist for that.
- Extra latency is possible. Visitor to edge to backbone to the colo with your tunnel to your machine can be longer than a direct path, especially if your connector's colos are far from your users.
- Opaque when it breaks. Half the path is a black box. You debug from logs and
cloudflared tunnel info, not tcpdump.
Alternatives
| Option | How it works | Pick it when | Watch out for |
|---|---|---|---|
| Port forward + DDNS | Router maps :443 to the host | You have a real public IPv4 and want zero dependencies | CGNAT kills it, IP exposed, you own DDoS and TLS |
| IPv6, no NAT | Host has a global address, firewall allows :443 | Your ISP and users all have IPv6 | Many visitors are still IPv4-only |
| Tailscale Serve / Funnel | WireGuard mesh; Funnel exposes a node publicly via Tailscale relays | Private access for you and your devices (Serve); quick public share (Funnel) | Funnel is limited to ports 443/8443/10000 and is not a CDN; no WAF |
| ngrok | Same outbound-tunnel idea, commercial | Dev previews, webhooks, request inspection UI | Paid for custom domains and serious traffic |
| VPS + WireGuard + Caddy/nginx | Home box dials a cheap VPS over WireGuard; VPS reverse-proxies | You want end-to-end control and your own TLS keys | You run, patch and DDoS-protect the VPS yourself |
| frp / rathole / Pangolin | Self-hosted reverse tunnel servers (Pangolin adds a dashboard and auth over WireGuard) | Cloudflare Tunnel's design, minus Cloudflare | Same VPS caveats, smaller ecosystems |
| zrok / OpenZiti, NetBird, ZeroTier | Overlay or zero-trust networks, often self-hostable | You need private connectivity and open source | Different mental model; public exposure needs extra pieces |
| Cloud load balancer + private subnet | AWS ALB / GCP LB in front of private instances | Production in a single cloud | Costs money; tied to that cloud's network |
Public website or API, want DDoS/WAF/TLS for free, fine with Cloudflare seeing plaintext: Cloudflare Tunnel. Only you and your devices need it: Tailscale (no public exposure at all is the strongest posture). Must keep TLS keys or carry raw TCP/UDP publicly: VPS + WireGuard or frp. Throwaway demo of localhost: quick tunnel (cloudflared tunnel --url http://localhost:3000) or ngrok.
A short history
- 2017: beta, briefly called Cloudflare Warp (not the WARP VPN client that came later).
- 2018: launches as Argo Tunnel, a paid add-on tied to Argo Smart Routing.
- 2021: made free for everyone and renamed Cloudflare Tunnel, folded into Cloudflare for Teams, later Cloudflare One.
- QUIC replaces HTTP/2 as the default transport, which unlocks UDP and ICMP for private networks.
- Remotely managed tunnels move config into the dashboard and push it down the tunnel, which is what lets one token configure a fleet.
Interview prep: questions I'd expect
Walk me through what happens when someone opens auth.kaplabs.dev.
DNS returns a proxied CNAME to <uuid>.cfargotunnel.com, which resolves to Cloudflare anycast IPs. BGP anycast lands the browser on the nearest PoP. TLS terminates there with an edge certificate; WAF, bot rules and any Access policy run. The edge maps the hostname to the tunnel UUID, looks up which data centers hold live connections registered by that tunnel, and forwards over the backbone to one of them. That PoP opens a new stream on an existing QUIC connection cloudflared dialed earlier. cloudflared matches the ingress rule and proxies to localhost:9000. The response goes back up the same path.
Why does an outbound connection get through NAT when an inbound one doesn't?
NAT creates a translation entry when a packet goes out; replies match that entry and are let back in. Unsolicited inbound packets match nothing and are dropped. cloudflared's connection is outbound, so the edge's "requests" are really just more traffic on an established flow. Keepalives stop the NAT entry from expiring, which matters most for UDP, where NAT timeouts are short.
What happens if cloudflared crashes mid-request?
In-flight streams on that process fail, and the visitor sees an error for those requests. Its connections unregister (or time out), and the edge sends new requests to another replica's connections if one exists, otherwise 1033. A graceful shutdown (--grace-period) stops taking new streams and drains in-flight ones first. Idempotent requests could be retried at the edge; non-idempotent ones can't safely be.
Why QUIC instead of HTTP/2 for the tunnel transport?
Many unrelated visitors' requests share one connection. Over TCP, one lost packet stalls all of them (head-of-line blocking). QUIC recovers loss per stream. QUIC also carries unreliable datagrams, which private networking needs for UDP and ICMP, and it reconnects faster. The cost is that UDP is blocked or rate-limited on some networks, hence the HTTP/2 fallback.
How would you design the routing registry so any of 330+ PoPs can find a tunnel?
Key: tunnel UUID. Value: the set of (data center, server, connection id) entries that are live. Writes happen on connect/disconnect, which is rare next to reads; reads happen on every request, so replicate widely and cache at every PoP with short TTLs or push-based invalidation. Stale entries are survivable: if the chosen connection is gone, fail fast and retry another entry. Tie liveness to connection heartbeats so a dead server's entries expire. Prefer entries close to the receiving PoP, or to the connector, weighted by measured backbone latency. That's essentially a soft-state, eventually consistent directory, the same family as service discovery.
Is it actually secure? Cloudflare can read my traffic.
Against internet attackers it's a big improvement: no exposed IP, no open ports, Access before the app. Against Cloudflare itself it's a trust decision, because the edge terminates TLS. Mitigations: validate Access JWTs at the origin, encrypt sensitive payloads at the application layer, use mTLS between cloudflared and the origin for defense in depth, or pick an end-to-end option (Tailscale, a TCP tunnel to your own TLS) for data you won't let a third party see.
Replicas vs Load Balancer: when do you need which?
Replicas give failover for one tunnel: the edge picks the closest live connection, with no weighting or health checks of the app. A Load Balancer treats tunnels as origins in pools and adds active health checks against the application, weights, geo steering and session affinity. Replicas for "don't go down", a Load Balancer for "spread load and route intelligently".
If you could change one thing about Tunnel, what would it be?
A good answer shows you used it. Mine: app-level health in the tunnel status (Healthy while every request 502s is misleading), replica steering without buying a Load Balancer, and more visibility into which colo served a request and why, without opening a support ticket.
Further reading in this site
- NAT: why outbound works and inbound doesn't
- TLS: what the edge terminates
- BGP: how anycast steers visitors
- HTTP/1.1 and HTTP/2: HTTP/2 vs HTTP/3 and head-of-line blocking
- Proxies and gateways and CDN
- VPN and IPsec: what Zero Trust private networking replaces
- Firewalls: stateful egress vs ingress rules
- Deploying Temporal with mTLS: where Tunnel was the wrong tool (gRPC from headless workers)