Private Networking on Hetzner Cloud with Tailscale | Onat Mercan’s Blog
This is a follow up to Why I Built My Own Kubernetes Cluster
The cluster post was about why I built my own Kubernetes cluster. This one is about the private network that makes the rest possible. I wanted the cluster to live in a VPC-style network with zero public node IPs, reachable only from my own internal network.
Tailscale is the bridge here. I keep Raspberry Pi Zero exit nodes across Europe with friends for VPN use, so I already use Tailscale a lot. That’s why it was the obvious choice for private access.
What I Wanted from the Network
I wanted three things:
Private-only cluster nodes : no public IPs anywhere except one gateway.
Tailnet-only kubectl : cluster access stays inside the Tailnet via subnet routes.
Separation of public vs. private apps : snapbyte.dev stays public, internal tools stay private.
That meant I needed a VPC-style network that could handle egress through a single NAT gateway, and ingress through an internal load balancer with a private IP address. The cluster could stay dark on the public internet, but still be reachable from my Tailnet.
Network Shape
Hetzner does not call this a VPC, but the architecture is the same idea: an isolated private network with a controlled egress point. I kept the design small and opinionated:
Network CIDR: 10.0.0.0/16
Subnet: 10.0.128.0/24
Single NAT gateway with a public IP for outbound traffic
Private-only Kubernetes nodes inside the subnet
The design is intentionally simple: network + subnet, a minimal firewall, a NAT gateway box, and Tailscale ACLs. The base network shape is small and explicit:
resource "hcloud_network" "vpc_network" {<br>name = "vpc-network"<br>ip_range = "10.0.0.0/16"
resource "hcloud_network_subnet" "vpc_subnet" {<br>network_id = hcloud_network.vpc_network.id<br>type = "cloud"<br>ip_range = "10.0.128.0/24"
The routing flow is simple and deterministic:
Cluster Node -> Subnet Gateway -> NAT Gateway -> Internet
The NAT gateway is the only box with a public IP. Everything else is private.
flowchart LR<br>subgraph Tailnet["Tailnet"]<br>Laptop[Laptop]<br>Phone[Phone]<br>end<br>Internet((Internet))
subgraph VPC["VPC-style network 10.0.0.0/16"]<br>subgraph Subnet["Subnet 10.0.128.0/24"]<br>subgraph K8S["Kubernetes Nodes"]<br>PublicApps["Public apps"]<br>InternalApps["Internal apps"]<br>end<br>PubLB["Public LB"]<br>PrivLB["Private LB"]<br>NAT[NAT Gateway]<br>end<br>end
Internet --> PubLB<br>PubLB --> PublicApps<br>Tailnet --> PrivLB<br>PrivLB --> InternalApps<br>Tailnet -->|kubectl over subnet route| K8S<br>K8S -->|egress| NAT<br>NAT --> Internet
The NAT Gateway and Egress
The NAT gateway is a Debian 12 server with two jobs: perform MASQUERADE NAT for the subnet and advertise the subnet into my Tailnet.
The NAT gateway is just a Hetzner server with a public IP and a default route for the network:
resource "hcloud_server" "nat_gateway" {<br>name = "vpc-nat-gateway"<br>image = "debian-12"<br>server_type = "cx23"<br>user_data = data.cloudinit_config.nat_gateway_cloud_init.rendered
network {<br>network_id = hcloud_network.vpc_network.id<br>ip = cidrhost(hcloud_network_subnet.vpc_subnet.ip_range, 1)
resource "hcloud_network_route" "nat_gateway_route" {<br>network_id = hcloud_network.vpc_network.id<br>destination = "0.0.0.0/0"<br>gateway = flatten(hcloud_server.nat_gateway.network)[0].ip
Cloud-init does the two important bits: NAT and Tailscale subnet routing.
#cloud-config<br>write_files:<br>- path: /etc/network/interfaces<br>content: |<br>post-up echo 1 > /proc/sys/net/ipv4/ip_forward<br>post-up iptables -t nat -A POSTROUTING -s '${network_ipv4_cidr}' -o eth0 -j MASQUERADE
runcmd:<br>- ['tailscale', 'up', '--auth-key=${tailscale_auth_key}', '--advertise-routes=${subnet_ip_range}']
The important detail is that cluster nodes never need public IPs. They just route their outbound traffic to the NAT gateway, and the gateway handles the rest.
The gateway sits behind a narrow firewall, so only the minimum network paths exist in and out.
Private Access with Tailscale
Tailscale is not just for logging in. It’s the entry point into the private network.
The network Terraform code manages Tailscale ACLs and subnet routes. When the gateway comes up, it advertises 10.0.128.0/24 to the Tailnet. My laptop and phone can reach any node or service inside the subnet.
resource "tailscale_acl" "vpc_nat_gateway_acl" {<br>acl = jsonencode({<br>tagOwners = { "tag:gateway" = ["autogroup:admin"] }<br>autoApprovers = { routes = { "10.0.128.0/24" = ["tag:gateway"] } }<br>acls = [{ action = "accept", src = ["autogroup:admin"], dst = ["*:*"] }]<br>})
This gives me a private network that feels like a LAN, but works across continents.
Internal Subdomains on a Private Load Balancer
The Kubernetes cluster sits behind two ingress controllers: one public, one private. The private one uses a Hetzner load balancer with a private IP on the subnet. That means I can map internal DNS names to that private IP and access internal apps only from my Tailnet.
The load balancers are not created here. They show...