Private Networking on Hetzner Cloud with Tailscale

onatm1 pts0 comments

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...

private network gateway subnet public tailscale

Related Articles