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:
| Aspect | Tailscale | Cloudflare Tunnel |
|---|---|---|
| Audience | Me and my own devices | Anyone I explicitly allow |
| Used for | Administration, SSH, internal tools | Web services that need a front door |
| Exposure | Private mesh, never published | Public 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.