Charlotte, NC
BlogApril 11, 2026

Cloudflare Zero Trust for Browser-Facing Homelab Services

Blake McCarn
Cloudflare Zero Trust for Browser-Facing Homelab Services
When I imported my Cloudflare Tunnel into Terraform, the ingress table had 36 hostname routes and a wildcard. Three of those routes were for services I no longer ran, and I deleted them in the next commit, 18 minutes after the import. They had been sitting in the dashboard the whole time, looking like every other row. As three removed blocks in a diff, they were hard to miss. That table is the public edge for the browser-facing side of my homelab. On a phone or a borrowed machine where I don't want to install a client, I open service.mccarn.tech, sign in with Google through Cloudflare Access, and the request reaches the service through a tunnel that only ever connects outward. SSH, kubectl and routed access to private subnets go over Tailscale instead, which I wrote about in Tailscale First, Cloudflare Tunnel Still in the Stack. This post covers the Cloudflare half. Cloudflare Zero Trust checks each request at Cloudflare's edge before anything reaches my network. Passing that check gets you an HTTP request to one hostname. It doesn't put you on the LAN. Everything goes through a single remotely managed Cloudflare Tunnel. Three cloudflared connectors run it. One is a Docker container on the Docker host, and the other two are replicas of a Kubernetes Deployment. All three start from the same tunnel token, and none of them has a config.yml. A remotely managed tunnel keeps its ingress rules on Cloudflare's side, and each connector receives them from Cloudflare when it connects. Each connector opens four outbound connections to Cloudflare, spread across at least two data centers, so the tunnel holds twelve connections when all three connectors are up. Cloudflare's tunnel availability docs say plainly that replicas aren't a load balancer. A request goes to the geographically closest replica. If that connection fails, Cloudflare retries another replica, with no promise about which one. That's the behavior I want. A connector should be able to disappear without me noticing, and I have no use for traffic steering. The two Kubernetes replicas have a topology spread constraint that keeps them on different nodes, a PodDisruptionBudget with minAvailable: 1, and a readiness probe on cloudflared's /ready metrics endpoint. A node drain can evict one of them, but not both at once. The Docker connector runs outside the cluster, so if the cluster is down, requests for hosts outside it still have a connector to go through. cloudflared checks ingress rules from top to bottom and uses the first match. My table relies on that order. Exact hostname routes come first, the *.mccarn.tech wildcard sits near the bottom, and the last rule is the required catch-all that returns a 404. As of writing there are 20 exact routes. Thirteen of them point at hosts and VMs outside Kubernetes, like Home Assistant and a few admin consoles, each at its own LAN address and port. The other seven point at the Kubernetes gateway by name. The wildcard is what keeps the table short. It sends every first-level *.mccarn.tech hostname without an exact route to Envoy Gateway in the cluster, and a proxied wildcard CNAME in DNS points those names at the tunnel. Envoy matches the Host header against Gateway API HTTPRoutes. A new Kubernetes app becomes reachable through the tunnel, behind Access, as soon as its HTTPRoute attaches to the shared gateway. Nothing changes in Cloudflare. The "first-level" part is a real constraint. Cloudflare's Universal SSL certificate covers the apex and first-level subdomains. app.mccarn.tech gets a valid edge certificate, and app.k8s.mccarn.tech wouldn't get one without Advanced Certificate Manager. So every app gets a name one level deep. On my LAN and tailnet, split DNS resolves the same hostnames to Envoy's internal address. Those clients never touch Cloudflare, and Envoy terminates their TLS with a wildcard certificate from cert-manager. The tunnel path is plain HTTP to Envoy's port 80, because Cloudflare already terminated TLS at its edge and the hop from cloudflared to Envoy stays on the LAN. Cloudflare Access sits in front of all of it as one self-hosted application whose destinations include *.mccarn.tech. Google is the only identity provider the application allows, and sessions last 24 hours. Two policies are attached in order. The first is a Service Auth policy for service tokens, which is how scripts and scheduled jobs get through without a login page. The second allows my own Google account. One application means one session. Signing in once covers every hostname under it, so Access gives me an identity check in front of everything rather than separate permissions per service. With one human user, that's the right amount of policy. When a request passes Access, Cloudflare adds a signed application token in the Cf-Access-Jwt-Assertion header. For a Google sign-in, its payload carries my email, the application's audience tag and the expiry. Cloudflare's guidance is to validate that token against your team's public keys instead of trusting that the header is there. In my setup, split DNS turns that guidance into a requirement. LAN and tailnet clients reach Envoy without passing through Cloudflare, so their requests carry no Access token at all. A service that relies on the Access header for identity breaks for every client on the inside. A service that accepts the header without validating it trusts whatever a LAN client decides to send. So the services that need identity keep their own logins, and Access stays an outer gate. The LiteLLM proxy is a good example. Its admin UI still requires its own login, and API calls still need a virtual key, whichever path the request took. One route gets a second check. The tunnel route for the storage appliance's admin console has cloudflared's origin-side Access validation turned on, with my team name and the application's audience tag. cloudflared validates the JWT itself before it proxies anything to that origin. The import brought the Zero Trust setup into the prod root of my homelab Terraform repo. That covers the tunnel resource with config_src = "cloudflare", the remote tunnel configuration that holds the ingress list, the wildcard Access application and its policies. Every one of those resources has prevent_destroy, so a plan that would delete the tunnel or the Access application fails outright. DNS lives in the same root, and the proxied wildcard CNAME to the tunnel is one more cloudflare_dns_record next to the LAN records Terraform already owned. The tunnel is still remotely managed. Terraform writes the same configuration the dashboard edits, through the cloudflare_zero_trust_tunnel_cloudflared_config resource, and the connectors still run from a token with no local file. Nothing changed on the connector side. What changed is how edits reach the table. Here's a trimmed version of that resource, with placeholder hostnames and addresses: The Access side is split across resources the way version 5 of the Cloudflare provider expects. Each cloudflare_zero_trust_access_policy holds a policy body, and the application's policies list decides which policies are attached and in what order: Changes go through a pull request. Atlantis runs in the cluster, plans the prod root automatically when a .tf file changes, and posts the plan on the PR. A route change is one block added to or removed from the ingress list. For something that decides what the internet can reach, I want the change to be about that small. After atlantis apply succeeds, Atlantis merges the PR. A nightly drift check plans the root again, so an edit made in the dashboard shows up as a difference from the code instead of silently replacing it. Atlantis reads the Cloudflare API token from AWS Secrets Manager at runtime, so the token isn't in the repo. Cloudflare provider upgrades arrive as Renovate PRs and get the same plan review. I wrote more about the Atlantis setup and its trade-offs in Terraforming My Homelab. Adding a service now takes one of two paths. A Kubernetes app needs only its HTTPRoute, which Flux applies from Git. The wildcard DNS record, the wildcard tunnel route and the wildcard Access application already cover the hostname. A service outside the cluster needs one exact ingress block above the wildcard, in a PR with a plan to read. DNS and Access already cover that hostname too. cloudflared defaults to --protocol auto, which uses QUIC and falls back to HTTP/2 only when the connector can't open UDP connections. My Kubernetes connectors log protocol=quic when they register. Every connection is outbound. The tunnel needs no port forward, no inbound listener and no public address on the origin side. With a port-forwarded nginx or Traefik, anyone on the internet can reach the proxy, and any bug in it. Here an origin only sees requests that came through Cloudflare's edge and an Access check, or requests from inside my own network. That second group never meets Access at all, which is why the services that matter keep their own logins. SSH, kubectl and database connections don't use Cloudflare. They go over Tailscale with ordinary clients. Cloudflare can render SSH in the browser, but I'd rather have a real terminal and my normal SSH config. The router, the NVR and the rest of the network management interfaces have no tunnel route. They stay on the private network. Automation that needs a protected service at 3 AM uses a service token against the Service Auth policy, or calls the service from inside the network with that service's own API key.
  • Cloudflare Tunnel, remotely managed, with three cloudflared connectors (one Docker, two Kubernetes replicas) connecting over QUIC
  • Cloudflare Access with one wildcard self-hosted application, Google as the only identity provider, and a Service Auth policy for service tokens
  • Envoy Gateway and Gateway API HTTPRoutes behind the *.mccarn.tech tunnel route
  • Split DNS so LAN and Tailscale clients reach Envoy directly
  • Terraform with the Cloudflare provider (v5) for the tunnel, its ingress configuration, DNS and Access
  • Atlantis for pull-request plans and applies, with a nightly drift check
  • Flux for the Kubernetes side, including HTTPRoutes
Sharing one tunnel across three connectors comes with a rule that no plan enforces. Cloudflare sends a request to the closest connector, not to the one sitting next to the origin. Every origin in the ingress table has to be reachable from the Docker host and from any worker node that can run a cloudflared pod. A route that only works from one of them fails whenever Cloudflare picks a different connector, and Terraform has no way to see that. A plan can confirm the table says what I meant. Whether all three connectors can reach what it points at is a check that has to happen outside Terraform.
Share this post: