← All posts

A Zero Trust front door with Cloudflare Tunnel and Access

Moving my domain to Cloudflare and publishing homelab services through replicated Tunnel connectors, with Access on every route and JWT validation at the connector.

4 min read
Private repository — walkthrough on request (ask)

The old way to share a home service with the internet is to open a port, forward it and hope. I wanted the opposite: services that are reachable from anywhere, by me, with no inbound ports open and an identity check in front of every request.

That is the Zero Trust front door. Here is how I built it, and the rule that keeps it coherent: Tailscale is private, Tunnel is public.

Step one: move the domain

Everything starts with DNS. I moved my domain to Cloudflare so that one control plane owns DNS, certificates, the tunnel and the access policies. It is managed as code, like the rest of the lab, so changes are reviewed and reproducible rather than clicked together.

Tunnel: connections go out, not in

Cloudflare Tunnel inverts the exposure model. A small connector inside the network opens outbound connections to Cloudflare, and Cloudflare routes requests for my hostnames down those connections. Nothing listens on the public internet at home, so there is nothing to scan and nothing to forward.

Replicas, because one connector is a single point of failure

A connector is a process, and processes die. So the tunnel runs with replica connectors, each in its own dedicated LXC container on a different node. Each one keeps its own connections to Cloudflare; traffic goes to the nearest healthy connector and fails over to the other, so I can restart or break a connector and the front door stays open.

Giving the connectors their own small containers, rather than piggybacking on an application host, keeps the blast radius small. A connector holds a tunnel credential and does one job. It should live somewhere that does nothing else.

Access on every route

A tunnel on its own just makes a service reachable. The security comes from Cloudflare Access, an identity-aware proxy in front of each route. A request without a valid session never reaches my network, it gets bounced to a login.

My rule is that no hostname is published without an Access application, and I enforce that in code review. “Public” in my setup means reachable, never anonymous.

Validate the token at the connector

Access hands the origin a signed JWT with each authenticated request. It is tempting to treat Access as a gate and trust everything behind it. I do not.

So every route is configured so that the connector itself refuses any request that does not carry a valid Access token for that application:

  • signed with the keys Cloudflare publishes for my team
  • issued by my team, for this application’s audience, so a token minted for a different application is rejected
  • not expired

Failing closed matters here. If the keys cannot be fetched or anything looks odd, the answer is no. This is defence in depth: if an Access policy were ever misconfigured, a request still has to carry a token that was actually issued for this application.

Tailscale is private, Tunnel is public

I run both Tailscale and Tunnel, and the temptation is to blur them. I keep a hard line instead:

AspectTailscaleCloudflare Tunnel
AudienceMe and my own devicesAnyone I explicitly allow
Used forAdministration, SSH, internal toolsWeb services that need a front door
ExposurePrivate mesh, never publishedPublic hostname behind Access

Administrative interfaces live on the private side and stay there. If something needs to be reachable by a browser on the open internet, it goes through the tunnel with Access. If it does not, it is not on the tunnel at all. That one rule removes most of the “should this be exposed?” arguments I used to have with myself.

What I would do again

  • Treat the edge as one layer, not the layer. Validate at the connector too.
  • Replicate the connectors from day one. It is cheap, and it turns maintenance into a non-event.
  • Write the publishing rule down. Every hostname gets an Access policy, no exceptions.

The setup is a good example of why I like this work: a handful of simple, well-understood pieces, arranged so that failure of any one of them still leaves the door locked.