Context
My homelab server runs 28 services. Most of them are private, and I wanted an easy way to reach them from outside. I chose Tailscale. Here is how I set it up.
The network underneath
Before the VPN setup, some context on how my Kubernetes cluster network is laid out.
Most of my services are private, a few are internet-facing. That split gave me two classes of service, and each class has its own namespace, network, and security rules (NetworkPolicies).
The cluster network runs on MetalLB for IP management and Traefik as the reverse proxy.
Each class gets one virtual IP from MetalLB: .210 for the public one, .220 for the private one. Traefik listens on both and routes each request to the right service, based on its hostname.
Public traffic goes through Cloudflare → port forwarding → Traefik on .210.
On the private side, the real lock is topological: the router never forwards to .220. The NetworkPolicies are defense in depth, not the first barrier.
I wanted remote access to the cluster, and to my whole local network. So I ran a VPN inside the cluster.
Routing summary:
Internet ── Cloudflare(proxy) ── port fwd 80/443/25565 ──► 192.168.1.210 traefik-public
LAN ──────────────────────────────────────────────► 192.168.1.220 traefik-private + CoreDNS (*.server)
Tailnet ── WireGuard/DERP ──► subnet-router pod ── SNAT ─► 192.168.1.220 (same as LAN)
Why Tailscale
I started with OpenVPN. The only container images I could find had been unmaintained for years, and simply didn’t work.
So I tried WireGuard. This time the image was fine. But reaching my whole local network from a container, with DNS living in another container, turned out to be pure hell.
Some of the issues I hit:
- The container needs a lot of host-level network permissions
- Kubernetes DNS unreachable from the VPN connection
- Local network unreachable from the VPN connection
- NetworkPolicies to get right
- A port to forward on both the router and the firewall
- Auth and encryption keys to store safely, and to ship safely to every device
- Parameter names that change between versions (a lot of the documentation online is obsolete)
- A very long configuration
- Malicious behavior hard to spot and hard to block with a firewall, since the container holds host permissions
To be fair to WireGuard, the configuration did work in the end. But at what price? And was it safe to expose on the internet?
Then I heard about Tailscale: a proprietary VPN ecosystem built on WireGuard. Setup is far simpler. You log in, you give it your network information, you start the container, and voilà.
Everything else is configured on Tailscale’s website, and a lot of advanced features come built in:
- Advanced filtering (by operating system, for example)
- Multi-user support
- Logs
- Internal DNS (still fully customizable)
- Automated ACL tests (they catch the security hole you’d open by disabling a rule by accident)
- A full JSON editor
The point was not to spend hours building a homelab VPN. That’s exactly what Tailscale gave me.
The subnet router
To let the VPN reach the local network, I spent a lot of time trying to give Tailscale an IP on the LAN. Every attempt broke access to my private services: the routing got too complex, and filtering malicious traffic got harder.
So I flipped the problem around, and gave my VPN clients an IP on the Kubernetes private network instead.
The NetworkPolicies still apply, so a tailnet client only reaches what it’s allowed to reach. No access to Redis or PostgreSQL. Access to their web interfaces, yes.
The downside: my private Traefik logs never show which tailnet user did what. One pod IP for everyone.
Chaining the DNS layers
DNS was the part that took the most work, because I had three resolvers to chain.
My internet provider’s router handled general resolution, with Quad9 as a privacy fallback. A CoreDNS instance in Kubernetes held the IPs of my private services (*.server). And Tailscale brings its own DNS layer, MagicDNS, which supports split DNS: routing a specific domain to a custom resolver.
Chaining them, from the top down:
- MagicDNS uses split DNS and points *.server at CoreDNS on .220 (reachable from the tailnet connection, so it works)
- CoreDNS forwards everything else to the internet provider resolver
- the internet provider router falls back to Quad9
That gives a full chain, with a clear resolution priority.
Local devices use CoreDNS (.220) as their resolver. Remote devices get theirs from the Tailscale configuration.
CoreDNS (192.168.1.220) ─┬─ *.server ────────► authoritative, private service VIPs
└─ everything else ──forward──► ISP router DNS ──secondary──► Quad9
This lets me reach any private service from any of my devices. Local ones through a plain DNS forward:
Local device ─────── all queries ──────────────────────────────► 192.168.1.220 CoreDNS
Remote ones through the VPN’s DNS configuration:
Tailnet device ─┬─ *.ts.net ─────► MagicDNS
├─ *.server ─────► split DNS ──► subnet-router pod ── SNAT ──► 192.168.1.220 CoreDNS
└─ anything else ─► the device's own resolver
Don’t forget to open the NetworkPolicies for DNS: port 53, UDP and TCP.
Still some manual work
Tailscale is easy to set up on the server side. But some settings only exist in Tailscale’s UI, so I configured them by hand, once:
- ACLs
- tagOwners
- Route approval
- RBAC on the apiserver proxy
- Groups
- Filtering
- DNS
- and more
Here is a basic configuration for a Kubernetes setup. The grants block is Tailscale’s default and allows everything: keep it tag-scoped, never open it to all users.
// Example/default ACLs for unrestricted connections.
{
// Define the tags which can be applied to devices and by which users.
"tagOwners": {
"tag:k8s-operator": [],
"tag:k8s": ["tag:k8s-operator"],
},
"autoApprovers": {
"routes": {
"192.168.1.0/24": ["tag:k8s"],
"10.1.0.0/16": ["tag:k8s"],
"10.152.183.0/24": ["tag:k8s"],
},
},
// Define grants that govern access for users, groups, autogroups, tags,
// Tailscale IP addresses, and subnet ranges.
"grants": [
// Allow all connections.
// Comment this section out if you want to define specific restrictions.
{"src": ["*"], "dst": ["*"], "ip": ["*"]}
],
// Define users and devices that can use Tailscale SSH.
"ssh": [
// Allow all users to SSH into their own devices in check mode.
// Comment this section out if you want to define specific restrictions.
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"],
},
],
}
I didn’t automate any of it. Terraform could, but that felt like overengineering for a one-shot setup.
Conclusion
Tailscale is the pick I’d make again. Much easier to set up than its open source competitors, and easier to secure correctly. The UI helps too: a real JSON editor, and built-in ACL tests that catch mistakes before they ship.
Two things I traded away: per-device attribution in my private logs, and a chunk of configuration that doesn’t live in my repo. Both were worth it. That doesn’t make them free.