Skip to main content

Part 21: The private door

TL;DR

  • This is the Tailscale story post 8 deliberately deferred: node-to-node mesh was that post's job; this one is about the operator, and how other things talk to the cluster.
  • The frame: not "VPN to get back home," but a powerful networking control plane used properly. Two kinds of doors live side by side: the public door (Authentik-fronted services, whose OIDC endpoints must be publicly reachable) and the private door (admin UIs, backends, dashboards that should never be public at all).
  • The operator runs Tailscale as a Kubernetes workload and publishes services into the tailnet selectively - proxy groups choose what's exposed, which is least privilege applied to the network layer.
  • The result: every service gets the access model it deserves. Public services get identity-checked front doors. Admin surfaces get existence hidden behind the mesh. No port bounties, no "is your Grafana public?" anxiety.

The deferred story, briefly

Post 8 drew a line on purpose: Talos ships the Tailscale client for node-to-node networking - the mesh that keeps the cluster's plumbing working across networks - and that was all that post needed. It pointed at the operator, the Kubernetes-native side, and said "later." This is the later.

Two kinds of doors

Here's the distinction that makes everything click, and it took me embarrassingly long to state it cleanly. There are two kinds of doors into an estate like mine, and they have opposite requirements.

The first kind: services that must be publicly reachable. Anything doing OAuth or OIDC federation needs its endpoints to be public - that's how the protocol works. If Authentik is the identity provider for an external service, that service needs to reach the OIDC endpoint without a VPN, without a tailnet membership, without any pre-existing trust. Public is not a design flaw there; it's the contract. So those services get the full treatment: CloudFront edge, firewall admission, Traefik routing, Authentik checking identity at the door. The public door is elaborate because the protocols demand it.

The second kind: everything else. Admin UIs. Backends. Database management surfaces. The Grafana nobody else should be logging into. The git server's admin panel. The mail server's interface. For these, the question "who can reach it?" gets a different first answer: nobody who isn't already mine. Not "nobody unauthenticated" - nobody unreachable, unless they're on the mesh. The URL doesn't resolve from the internet. The port isn't listening. The surface doesn't exist, as far as the public internet is concerned. And that's not instead of authentication - it's in front of it. The services behind the tailnet still speak OIDC; Authentik still stands at the door and checks identity exactly as it does on the public side. Tailscale answers "are you even allowed to attempt this," Authentik answers "who are you and what may you touch." Reachability is the outer fence; identity is the door. The two compose, and the composition is the point: private services get both fences, public ones get the identity fence plus the full public chain.

The traditional way to get the second kind was a VPN: a concentrator, listening on a public port, that you dial into. Which works, and immediately recreates the problem: you've just built a public endpoint whose compromise is catastrophic. The door you hid just got a very high-value public copy.

The control plane you get for free

This is where Tailscale stops being "a VPN" and starts being what it actually is: a coordination plane that makes WireGuard behave like infrastructure. The mesh doesn't need listening ports; every node dials out, and the coordination layer brokers the handshake. Key rotation, ACL policy, node identity - all handled by a control plane that was designed by people who thought about the failure modes.

The Kubernetes operator is where it gets interesting for this estate. Tailscale runs as a workload, not as a daemon I install on every node and remember to maintain. When I want a service published to the tailnet, I annotate it, and the operator stands up a proxy pod that joins the mesh and fronts the service. Which service gets published, to which identities, with what access - that's the proxy group story, and it's least privilege applied at the network layer: the network equivalent of what Authentik does for identity. Not every service is in the tailnet. The ones that are, are there on purpose, addressed on purpose, and reachable by identities I explicitly allowed.

The beauty of this arrangement is what it does to my public attack surface. My internet-facing footprint shrinks to the services that genuinely need to be public. Everything administrative goes dark. There is no Grafana login page for a botnet to find, no admin panel leaking its version number, no exposure surface at all - because none of it listens on the internet. The estate keeps its public doors, polished and identity-checked; and everything else simply isn't there.

The posture

So the border now has two faces. The public face: CloudFront at the edge, firewall doing admission, BGP announcing addresses, Traefik routing, Authentik authenticating - a chain of responsibilities, each doing its one job, exactly as the last two posts described. The private face: a mesh that follows the estate out of the house, that needs no inbound ports, that publishes only what I explicitly published, and whose access policy lives in the same declarative world as everything else.

The two faces don't bleed into each other. A service is public or it's tailnet-only; if it's both, each path gets the treatment its exposure deserves. And the mental model scales: when the second cluster from the site-to-site daydream materializes, it joins the same mesh the same way - another set of doors, same policies, same control plane.

flowchart LR
    subgraph PUB["The public door"]
        direction LR
        NET["Internet"] --> CF["CloudFront"] --> FW["Firewall"] --> TR["Traefik"] --> A1["Authentik<br/>identity"] --> SVC["Public services<br/>(OIDC endpoints)"]
    end
    subgraph PRIV["The private door"]
        direction LR
        DEV["My devices"] -->|"dials out -<br/>no listening ports"| TS["Tailscale mesh"] --> OP["Operator<br/>proxy pod"] --> A2["Authentik<br/>identity"] --> ADM["Admin UIs,<br/>backends, dashboards"]
    end

And here's the payoff nobody advertises: because Authentik sits at the center of the public door, moving a service between the two faces is nearly trivial. The service's provider in Authentik - the registration that describes who the app is and where its endpoints live - carries the redirect URLs and the OIDC/SAML endpoints. Change the service's exposure in the network layer (publish it to the tailnet, unpublish it from the edge), update the redirect URL in the Authentik UI to match, and the service has changed continents. Public to private, or private to public, without touching the application, without re-registering anything at the provider, without a migration project. The centralization I chose for identity in post 15 keeps paying rent: one place holds the truth about where each app's front door is, so relocating the door is a form edit.

That's the networking chapter closed: fabric, front door, private door. Three posts, one border, no leaks.