TL;DR Tailscale puts every device I own on one WireGuard mesh, so I reach the cluster in the closet from a coffee shop without a single port forwarded on the router. Subnet router, exit node, the Kubernetes operator, ACLs, and Headscale for the part I’d rather host myself, in that order.
My homelab cluster runs in a closet at home. I don’t. I work from coffee shops, from a client’s office, and now and then from a hotel with WiFi that seems to resent me. I still want my own machines while I’m out there.
For years the standard answer was to poke a hole in your own front door. Forward a port on the router, set up dynamic DNS so the changing home IP doesn’t break everything, write the firewall rules, and hope nobody scanning the internet finds the SSH daemon you’ve just exposed. It works, you can reach your stuff. It also means part of your home network answers questions from strangers around the clock.
What I wanted was to reach the homelab from anywhere with nothing open to the internet. Tailscale does that for me, and this post is how I set it up, from one node to the whole cluster.
What Tailscale is
Tailscale is a mesh VPN built on WireGuard. Every device you enrol gets a stable address in the 100.x.y.z range, and every device talks directly to every other one. There’s no central box the traffic has to pass through, which is the difference from the VPN concentrator you may remember from an office job.
When the network cooperates, two devices connect straight to each other. When NAT gets in the way, and it often does, the traffic falls back to a relay through Tailscale’s DERP servers. You don’t manage any of that. Both paths are encrypted end to end, so even the relay can’t read what passes through it.
One trade-off before I go further. The coordination plane is Tailscale’s, not yours. They run the control servers that hand out keys and tell devices where to find each other. Your traffic stays peer to peer and encrypted, but the brokering depends on a company you don’t host. For a blog built on sovereignty that matters, and there’s a fix. I’ll get to it near the end.
Why I use it anyway
What sold me is what disappeared from my router config. Nothing is forwarded, nothing is exposed, and there’s no port for a scanner to find. The service I used to keep hardened against the whole internet doesn’t exist any more.
It also gets through network setups that would otherwise be a dead end. CGNAT from an ISP that won’t give you a real public IP, double NAT, hotel WiFi that blocks half the internet. Both ends reach out instead of waiting to be reached, so the mess of routers in between stops mattering. My homelab is just there, on my tailnet.
Then the smaller things. Each device keeps the same 100.x.y.z address, so I’m not chasing dynamic DNS records that lag behind reality. MagicDNS gives every machine a name like homelab.tail-abc123.ts.net, so I’ve stopped memorising IPs. And ACLs let me say who reaches what, so a borrowed laptop doesn’t automatically get the keys to the cluster.
Getting it running
Start with the homelab node itself.
# Ubuntu/Debian
curl -fsSL https://tailscale.com/install.sh | sh
# Start Tailscale
sudo tailscale up
# Check status
tailscale status
tailscale up prints a login link. Open it, log in, and the node joins your tailnet. Then put the client on whatever you carry around:
- Your laptop
- Your phone
- Anything else that needs in
Every device authenticates against the same Tailscale account, and once they have, they see each other. Check it from your laptop:
# From laptop
ping homelab.tail-abc123.ts.net
# SSH to homelab
ssh user@homelab.tail-abc123.ts.net
# Access services
curl http://homelab.tail-abc123.ts.net:3000 # Grafana
No port forwarding, no firewall rules. The first time I SSH’d into a machine at home from a train, having opened nothing, it felt slightly illegal.
Reaching the devices that can’t run Tailscale
Plenty of things on a home network will never run a Tailscale client. The NAS firmware doesn’t allow it, and the printer is a printer. A subnet router fixes that by turning one enrolled node into a doorway to the rest of the LAN.
# On homelab node
sudo tailscale up --advertise-routes=192.168.1.0/24
# Accept the route in Tailscale admin console
# Or use --accept-routes on clients
Once you accept the advertised route, your laptop reaches the NAS on 192.168.1.50 by routing through the homelab node, and the printer the same way.
Sending all traffic home
There’s a related trick for when you don’t trust the network around you at all. Turn a home node into an exit node and your laptop routes everything through it, so your traffic leaves from your home IP instead of the café’s.
# On homelab node
sudo tailscale up --advertise-exit-node
# Accept in admin console
# On laptop (when needed)
sudo tailscale up --exit-node=homelab
I flip this on when I’m on WiFi I don’t control, which gets me out from under whatever that network is logging. It also works for reaching geo-restricted things as if you were home, and for using your home Pi-hole wherever you are.
Putting cluster services on the tailnet
This is where it gets nice for a Kubernetes homelab. The Tailscale Kubernetes Operator publishes services straight onto your tailnet, so they’re reachable by name without an ingress facing the internet.
# Install operator
helm repo add tailscale https://pkgs.tailscale.com/helmcharts
helm repo update
helm install tailscale-operator tailscale/tailscale-operator \
--namespace tailscale \
--create-namespace \
--set oauth.clientId="<client-id>" \
--set oauth.clientSecret="<client-secret>"
Create the OAuth client in the Tailscale admin console first, then hand those credentials to the install. With the operator running, exposing a service is one annotation:
apiVersion: v1
kind: Service
metadata:
name: grafana
annotations:
tailscale.com/expose: "true"
spec:
selector:
app: grafana
ports:
- port: 3000
Grafana is now reachable at grafana.tail-abc123.ts.net:3000 from any device on the tailnet, and from nowhere else. If you’d rather treat Tailscale as a proper ingress controller, that works too:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
spec:
ingressClassName: tailscale
rules:
- host: grafana
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
That gives you grafana.tail-abc123.ts.net with HTTPS handled for you.
Deciding who can reach what
Enrolling devices is the easy half. By default everything on your tailnet talks to everything else, and that’s fine until you add a device you trust less. ACLs tighten it.
{
"acls": [
// Admin can access everything
{
"action": "accept",
"src": ["group:admin"],
"dst": ["*:*"]
},
// Developers can access cluster services
{
"action": "accept",
"src": ["group:dev"],
"dst": ["tag:k8s:80,443,6443"]
},
// Everyone can access monitoring
{
"action": "accept",
"src": ["*"],
"dst": ["tag:monitoring:3000,9090"]
}
],
"groups": {
"group:admin": ["user@example.com"],
"group:dev": ["dev1@example.com", "dev2@example.com"]
},
"tagOwners": {
"tag:k8s": ["group:admin"],
"tag:monitoring": ["group:admin"]
}
}
You tag devices in the admin console, and the ACL decides what each tag reaches. A dev box gets the cluster API and the web ports, the monitoring stack is readable by anyone on the tailnet, and admin gets the rest. It’s the same reason I write network policies inside the cluster, a smaller blast radius when something does go wrong.
Names and certificates
MagicDNS already gives every device a device-name.tail-abc123.ts.net name. In the admin console you can turn it on and swap the random tailnet string for something you’ll remember.
For TLS, Tailscale issues real certificates for tailnet domains:
tailscale cert grafana.tail-abc123.ts.net
That’s valid HTTPS for an internal service without dragging Let’s Encrypt or cert-manager into it. If you want your own domain in front, point a CNAME at the tailnet name and tell Tailscale to accept it:
# In your DNS
grafana.internal.example.com CNAME grafana.tail-abc123.ts.net
When you do need something public
Now and then you want one thing reachable from the open internet. A webhook endpoint, a link to hand someone for an hour, a quick demo. Tailscale Funnel gives you a public URL for that without touching your router.
# Expose local port 3000 publicly
tailscale funnel 3000
You get back something like https://homelab.tail-abc123.ts.net/. Funnel sits outside your ACLs, so whatever you funnel is public in the plain sense of the word. Treat it like a forwarded port, because that’s what it is, and only funnel things you’re happy for the world to poke at.
Owning the control plane
Back to the trade-off from the start. What nags at me about Tailscale’s coordination servers is what nags at me about any black box. If they’re down, or the company changes course, my access changes with it. For a homelab built on owning what you run, handing the key-and-discovery layer to someone else is a real concession.
Headscale is the answer. It’s an open-source reimplementation of the Tailscale control server that you host yourself, and the standard Tailscale clients talk to it without complaint.
# headscale deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: headscale
spec:
replicas: 1
selector:
matchLabels:
app: headscale
template:
spec:
containers:
- name: headscale
image: headscale/headscale:latest
args:
- serve
ports:
- containerPort: 8080
volumeMounts:
- name: config
mountPath: /etc/headscale
- name: data
mountPath: /var/lib/headscale
Same WireGuard underneath, same client experience, except the coordination now runs on something you control. The cost is that you run and update it yourself. In return, the mesh keeps working on the day Tailscale the company doesn’t.
Wiring it into the rest of your tools
A few integrations earn their keep every day. Tailscale can handle SSH auth, so you stop juggling keys for hosts that are already on your tailnet:
# Enable Tailscale SSH on server
tailscale up --ssh
# Connect (no keys needed!)
ssh user@homelab.tail-abc123.ts.net
Tailscale authenticates the connection with your tailnet identity, so there’s no key to copy around. Cluster access works the same way once the API server is on the tailnet:
# ~/.kube/config
apiVersion: v1
clusters:
- cluster:
server: https://k3s.tail-abc123.ts.net:6443
name: homelab
contexts:
- context:
cluster: homelab
user: admin
name: homelab
current-context: homelab
kubectl from a hotel room hits the cluster as if you were sitting next to it. And for editing on the box itself, one SSH config entry is enough for VS Code Remote to connect:
// .ssh/config
Host homelab
HostName homelab.tail-abc123.ts.net
User youruser
What my setup looks like
Nothing exotic. One node does the heavy lifting and my handful of devices connect to it.
The K3s
node is the subnet router for 192.168.1.0/24, so the NAS and everything else on the LAN are reachable. It’s also my exit node for the days I’m on WiFi I’d rather not trust. The Tailscale Operator publishes Grafana, ArgoCD and Forgejo onto the tailnet, and an ACL keeps the whole thing scoped to my own devices.
I check monitoring from anywhere. I’ve kicked off an ArgoCD sync from my phone on a train. And the router has never had a single port forwarded to make any of it work.
When it misbehaves
A few things that have tripped me up.
“Tailscale is blocked.” Some corporate networks block it outright. Move it to a different DERP port, or run it over TCP/443 so it looks like ordinary HTTPS and gets through.
“The connection is slow.” Usually the direct path failed and you’re going through a DERP relay. Check whether a firewall eats the UDP traffic Tailscale uses for direct connections, and whether your NAT type forces relaying.
“I can’t reach the subnet.” Subnet routes have to be accepted, not just advertised. Advertise on the router node with --advertise-routes, accept the route in the admin console, and turn on --accept-routes on the clients that need it.
Why this sticks
Instead of opening the network and then defending the opening, I open nothing. Reaching anything means authenticating onto the mesh first, encryption is on whether I think about it or not, and access is decided in one ACL file I can read in a minute.
So the cluster stays in the closet and I keep working from wherever I am, and from my side it behaves as if I never left the house. With Headscale in the mix, not even the connection brokering runs on somebody else’s servers. The router still has no ports forwarded, and it’s staying that way.
