Charlotte, NC
BlogApril 11, 2026

Cloudflare Zero Trust for Browser-Facing Homelab Services

Blake McCarn
Cloudflare Zero Trust for Browser-Facing Homelab Services
I used to reach everything on my home network through one VPN. One WireGuard connection and I had full LAN access from wherever I was. Phone, laptop, hotel Wi-Fi, airport lounge. It worked. It also meant that anything with the VPN key had access to everything: the NAS, the Docker host, the router admin panel, every service on every port. Cloudflare Zero Trust became my first serious alternative for browser-facing services. It no longer replaces the private network: Tailscale is now the primary operations path for SSH, Kubernetes, routed access, and recovery. Cloudflare remains the public application edge for selected web interfaces. This article describes that browser-facing layer and calls out the original architecture where it matters. That's the fundamental problem with VPN for service exposure. It's an all-or-nothing gate. Once you're in, you're in. The shift to Cloudflare Zero Trust wasn't about any single feature. It was a model change. Instead of "authenticate once, access everything," every request to every service gets verified independently. Identity is checked per-service, not per-network. Sessions expire. Access policies can be as granular as you want. I deploy Zero Trust network access in enterprise environments. ZTNA is one of those things that looks obvious in a slide deck but changes how you think about infrastructure when you actually live with it. Running it on my own homelab, where I control every layer of the stack, taught me things the enterprise version doesn't. The first thing it taught me: 32 services is a lot of services to migrate. The setup is a single Cloudflare Tunnel with multiple connectors. The original version used one connector on the Docker host and another on the NAS. The current version keeps a Docker connector and adds two Kubernetes replicas, all using the same tunnel identity. Cloudflare can route through any healthy connector without opening inbound firewall ports. Each connector maintains outbound connections to Cloudflare's edge network. Cloudflare load balances across the healthy connectors, and a connector can restart or disappear without changing DNS or opening an inbound firewall rule. The key design decision: all tunnel configuration lives in the Cloudflare API, not in local config files. There's no config.yml on the machines. The connectors authenticate with a tunnel token and pull their ingress rules from the Cloudflare dashboard. This means I can add, remove, or modify service routes without touching either machine. The 32 services in the ingress table cover everything from monitoring dashboards and container management to document archives, AI tools, media servers, and home automation. Each one maps a subdomain under mccarn.tech to an internal IP and port. The routing layer has grown up too. The first version had a tunnel ingress table with one route per service. The current Kubernetes path uses a wildcard tunnel route, Envoy Gateway, and HTTPRoutes. New Kubernetes apps usually attach to the shared gateway instead of needing a fresh Cloudflare route every time. Trusted LAN and Tailscale clients resolve the same hostnames through split DNS and reach the local Envoy address directly. I wrote about that division in Tailscale First, Cloudflare Tunnel Still in the Stack. I ran both. For about three months, Tailscale and Cloudflare Tunnel coexisted on the same infrastructure. I kept both because they solve slightly different problems, and I wanted to compare them honestly before committing. Tailscale is a mesh VPN built on WireGuard. It's genuinely impressive. Install it on two machines and they can talk to each other with almost zero configuration. The MagicDNS feature, the ACL system, the exit nodes, the subnet routers. All well-designed. For device-to-device connectivity, it's hard to beat. If your primary need is reaching specific machines from specific devices, Tailscale is probably the right answer. But my use case is different. I have 32 HTTP services that I want to reach from whatever device I happen to be using. Sometimes that's my laptop. Sometimes it's my phone. Sometimes it's a borrowed computer or a hotel business center. Tailscale's model requires the client installed on every device, and while its ACLs can lock down per-device access nicely, I still need the app present to reach anything. Cloudflare's model is browser-based. I open service.mccarn.tech in any browser on any device, authenticate with Google OAuth, and I'm in. No client to install. That distinction is what decided it for me. The tradeoff is real, though. Tailscale gives you raw network access (any protocol, any port), while Cloudflare Tunnel only proxies HTTP/HTTPS, SSH, and RDP. For SSH, Cloudflare can render a browser-based terminal. For protocols like raw TCP or UDP, Cloudflare's WARP client can be used, but at that point you're back to installing a client. For browser-facing services, Cloudflare is the better fit. For device-to-device connectivity, routed private access, and infrastructure operations, Tailscale is now my primary path. They solve different problems, and the current design keeps both. Every request that hits a *.mccarn.tech subdomain goes through Cloudflare Access. The policy setup is simpler than it sounds. I have a single wildcard application policy covering *.mccarn.tech. Google OAuth is the only interactive identity provider currently allowed, and the session duration is 24 hours. Automated clients use a separate non-interactive policy with Cloudflare Access service tokens. The Google OAuth integration requires a Google Cloud project with OAuth credentials. The authorized redirect URI points to my team's cloudflareaccess.com domain. It took about 10 minutes to set up, and it means signing into any of my 32 services is just a Google login prompt. The key insight here is the Cf-Access-Jwt-Assertion header. When a request passes through Cloudflare Access, Cloudflare adds a signed JWT to the request before forwarding it to the origin. That JWT contains the authenticated user's email address. Downstream services can read this header and know exactly who is making the request without implementing their own authentication. Some of my services use this directly. The LiteLLM proxy sits behind Cloudflare Access, and the JWT tells it which user is making API calls. Other services that have their own auth (like the NAS management interface) ignore the header and handle authentication independently. The point is that every request arriving at any service has already been identity-verified at the edge. The service can choose whether to use that identity or not. This is the workflow I actually use. No config files to edit, no connectors to restart.
  1. Create a DNS CNAME record: newservice.mccarn.tech → <tunnel-id>.cfargotunnel.com
  2. Add an ingress rule to the tunnel via the Cloudflare API, mapping newservice.mccarn.tech to the internal origin (for example, http://10.0.1.50:8080)
That's it. The wildcard Access policy already covers *.mccarn.tech, so the new service gets identity verification automatically. The connectors pick up the new ingress rule without restart. Total time from "I want to expose this service" to "it's live behind auth" is under 2 minutes. For newer Kubernetes-hosted services, the Cloudflare side is even quieter because the wildcard DNS and tunnel route already exist. I add an HTTPRoute to the shared Envoy Gateway, add the hostname to split DNS when trusted devices should stay local, and let the same Cloudflare Access policy cover the public path. The API flow below is still the shape I use for direct-origin routes outside Kubernetes. The API calls look like this: I've automated this with a script, but the underlying API calls are the entire process. No YAML files. No daemon restarts. No SSH into anything. This is the part where I broke everything. I was running a gateway service that handled its own authentication with username/password. It worked fine behind Cloudflare Access, but I wanted to simplify. Cloudflare Access already verifies identity. Why have the gateway ask for credentials again? The gateway supported a "trusted proxy" mode where it would read the authenticated user's email from a header (set by Cloudflare Access) and skip its own login screen. So I switched it. Turned on trusted proxy mode, configured it to trust the Cf-Access-Jwt-Assertion header. It broke immediately. Not just the external access. Everything. The problem: internal clients on the LAN also connect to this gateway. Those clients don't go through Cloudflare. They connect directly to the internal IP. In trusted proxy mode, the gateway expected every request to carry the Cloudflare JWT header. Internal clients don't have that header. They were getting rejected silently or, worse, treated as unauthenticated. This is the danger of all-or-nothing auth mode switches. The gateway doesn't know whether a request came through Cloudflare (and therefore has a valid JWT) or came directly from the LAN (and therefore has no JWT). It just looks at the header. If the header isn't there, the request fails. The recovery was manual. SSH into the machine, edit the gateway's configuration file to revert to standard authentication mode, restart the service. Took about 15 minutes, but those 15 minutes included every internal automation and client failing to connect. The lesson: if a service has both external (proxied through Cloudflare) and internal (direct LAN) clients, you cannot use a single auth mode that assumes all traffic is proxied. You need either dual-mode authentication or a way to detect whether a request came through the proxy. Some gateways support this natively. This one didn't, and I found out the hard way. I now test auth mode changes on a staging instance first, with a documented rollback procedure written down before I touch the production config. Every time. The cloudflared daemon connects to Cloudflare's global network using QUIC (with HTTP/2 as fallback). Every connection is outbound. No inbound ports are opened on the firewall. No port forwarding rules. No dynamic DNS. This is a significant security improvement over traditional reverse proxy setups. With a typical nginx or Traefik setup, you open ports 80 and 443 on your firewall and forward them to the proxy host. That proxy host is now directly reachable from the entire internet. If there's a vulnerability in the proxy software, attackers can reach it. With Cloudflare Tunnel, the only traffic that reaches your origin is traffic that Cloudflare has already inspected and proxied. The firewall can block all inbound traffic. The attack surface for direct-to-origin attacks is zero because there's no inbound listener. QUIC also matters for connection resilience. QUIC handles connection migration natively, so if the underlying network changes (laptop moves from Wi-Fi to cellular, for example), the tunnel connections survive without re-establishing. In practice, my tunnels stay connected for weeks at a time with zero drops. Not everything can use browser-based authentication. Services that need non-HTTP protocol access (like SSH from a terminal emulator or database connections) don't go through Cloudflare Access in my setup. I use SSH keys and direct connections for those. Cloudflare does offer browser-rendered SSH, but I prefer a real terminal. API endpoints called by automated scripts bypass the interactive Google login, not Cloudflare Access itself. An automation that needs to call a service at 3 AM uses a Cloudflare Service Token with a separate non-interactive Access policy, or the service validates its own API key behind the tunnel. The router, NVR, and network management interfaces stay internal-only. These are accessed via direct IP on the LAN. I don't expose network infrastructure management through any tunnel, even with authentication in front of it. Some things belong on the local network.
  • Cloudflare Tunnel with multiple cloudflared connectors for HA (QUIC protocol, outbound-only)
  • Cloudflare Access for identity-aware per-service authentication
  • Google OAuth as the interactive identity provider, with service tokens for automation
  • Wildcard Access policy covering *.mccarn.tech with 24-hour session duration
  • Cloudflare API for direct-origin DNS and tunnel ingress management
  • Envoy Gateway + HTTPRoutes for newer Kubernetes-hosted services behind the same tunnel
  • 32 original HTTP services, now expanded by Kubernetes-hosted apps using the same hostname pattern
If the thing you need is browser access to selected homelab services, Cloudflare Tunnel with Access is a strong fit: no inbound ports, per-service identity verification, and a clean unmanaged-device experience. It complements rather than replaces the private Tailscale operations network.
Share this post: