Part 9: Connecting My Homelab to Headscale with a Tailscale LXC Router

headscaletailscaleproxmoxlxcpfsensepiholehomelabk3snetworking

Homelab Arena Part 9: Connecting My Homelab to Headscale with a Tailscale LXC Router

The Headscale setup started as part of my DevOps Odyssey. I wanted to understand what it would feel like to self-host the coordination server behind a Tailscale-style network, so I deployed Headscale on my OCI K3s cluster and connected a couple of regular Tailscale clients to it.

That was a useful experiment by itself, but it did not stay only on the cloud side for very long. Once my laptop and phone could join the Headscale network, the next question was obvious: could this become a private bridge back into my homelab?

That is why this post fits better in Homelab Arena. The control plane still lives on OCI K3s, but the result matters most inside my home network. I was not trying to expose home services to the public internet. I wanted the opposite. I wanted the services to stay private, while still being reachable from trusted devices when I was away from home.

I also did not imagine, when I first built the homelab K3s cluster, that it would eventually work together with another K3s cluster running in OCI. One cluster runs the home services. The other runs the coordination server. A small router inside my homelab connects the two worlds.

That router is a Proxmox LXC container running the regular Tailscale client. It became both a subnet router for my home networks and an optional exit node for my trusted devices.

Homelab Arena Part 9 - Connecting the homelab to Headscale


What I wanted

The goal was not to make the homelab public. I do not want dashboards like Proxmox, pfSense, Pi-hole, Argo CD, or internal applications exposed directly to the internet just because I occasionally need to reach them from outside the house.

What I wanted was a private access path. When my laptop or phone joins the Headscale network, it should be able to reach selected home subnets through a trusted route. DNS should still work through Pi-hole. Internal names should still feel like internal names. If a service is normally available as blog.c.home when I am at home, I wanted that same name to work when I am away.

There was also a second use case that made sense once the router existed. Sometimes I want my device to send all traffic through home, not just traffic for the homelab subnets. That is what a Tailscale exit node is for. The same LXC can advertise home routes and also become an optional exit node, so my laptop or phone can choose to egress through my home network when I need it.

So the target was not just:

remote device -> homelab subnet

It was closer to:

remote device -> Headscale -> home router LXC -> homelab services
remote device -> Headscale -> home router LXC -> internet through home

The first path lets me reach internal services. The second path lets my trusted devices behave more like they are on the home network when I choose the LXC as the exit node.

The shape of the setup

The final shape is easier to understand if I separate the control plane from the home network.

Headscale runs outside my house on the OCI K3s cluster. My laptop and phone use the regular Tailscale client, but instead of logging into Tailscale’s hosted coordination server, they point to my Headscale URL.

Inside the homelab, I created a small Debian LXC container on Proxmox. That container also runs the regular Tailscale client and joins the same Headscale network. The difference is that this container advertises routes for my home LAN subnets and also advertises itself as an exit node.

The flow looks like this:

Laptop or phone outside home
        |
        | Tailscale client
        v
Headscale on OCI K3s
        |
        | private tailnet route
        v
tailscale-router LXC at home
        |
        +--> home LAN routes
        |       |
        |       v
        |   Homelab services and Pi-hole DNS
        |
        +--> optional exit node
                |
                v
            Internet through home

That is the bridge. Headscale coordinates the devices, but the LXC router provides the useful path back into the home network. When I only need internal services, subnet routing is enough. When I want all traffic to leave through home, I can select the same LXC as the exit node from the Tailscale client.

This keeps the responsibilities clean. OCI K3s does not run my home services. Homelab K3s does not need to run the Headscale control plane. The LXC router is the small piece that makes them useful together.

Why I used a Proxmox LXC

I considered a few options for the router. I could have used a full VM. I could have installed Tailscale directly on an existing host. I could have tried to run it inside the homelab K3s cluster. All of those would probably work, but none of them felt quite right for this job.

The router is not really an application. It is part of the network path. I wanted it to be small, predictable, and close to the home networking layer. In my setup, that means Proxmox, pfSense, and Pi-hole.

So I created a dedicated LXC container named tailscale-router.

The container does not need much:

Hostname: tailscale-router
OS: Debian 12
CPU: 1 core
Memory: 512 MB
Disk: 4 GB
IP: 10.0.1.90/24
Gateway: 10.0.1.1
DNS: 10.0.0.99, 1.1.1.1

This is one of those cases where boring infrastructure is a good thing. The router does not need a UI. It does not need application storage. It does not need to be part of the Kubernetes cluster. It only needs to stay online, join Headscale, advertise the correct routes, and be available when I want to use it as an exit node.

Preparing the LXC for Tailscale

There was one Proxmox LXC detail that mattered before Tailscale could work properly: the container needed access to /dev/net/tun.

Tailscale uses a TUN device to create its network interface. In a normal VM, that is usually available without much thought. In an LXC container, I had to pass it through from the Proxmox host.

For my container, the ID was 102, so I edited this file on the Proxmox host:

/etc/pve/lxc/102.conf

I added these lines:

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

Then I rebooted the container:

pct reboot 102

After the container came back, I checked for the device inside the LXC:

ls -l /dev/net/tun

This step is easy to overlook because Tailscale can still install without it. The problem shows up later when the service tries to create the tunnel interface. I would rather handle the LXC permission clearly upfront than troubleshoot a half-working router later.

Installing and preparing Tailscale

Inside the container, I installed Tailscale using the normal install script:

curl -fsSL https://tailscale.com/install.sh | sh

Because this container will route traffic between the Tailscale network and the home LAN, IP forwarding needs to be enabled. It also matters for exit-node behavior, because the router may forward traffic from a remote device out through the home network.

cat <<'EOF' > /etc/sysctl.d/99-tailscale-router.conf
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF

sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1

Then I enabled and started the Tailscale daemon:

systemctl enable --now tailscaled

At this point, the LXC was ready to become a Headscale node.

Joining the router to Headscale

On the OCI K3s side, Headscale already had my user. I generated a short-lived pre-auth key so the router could join without setting up OIDC or a browser login flow.

kubectl -n headscale exec deploy/headscale -- \
  headscale preauthkeys create \
  --user janus \
  --expiration 1h

Then, inside the LXC, I joined the router to Headscale:

tailscale up \
  --login-server https://janus-headscale.duckdns.org \
  --authkey <KEY> \
  --hostname tailscale-router

After that, I checked the Headscale node list:

kubectl -n headscale exec deploy/headscale -- \
  headscale nodes list

Seeing tailscale-router appear in Headscale was the first real checkpoint. At that point, the home network had a device inside the same private network as my laptop and phone.

But it was still only a node. It was not routing the homelab yet.

Advertising the home routes and exit node

The next step was to tell Tailscale what this LXC could provide.

In my homelab, the important networks are:

10.0.0.0/24
10.0.1.0/24
10.0.2.0/24

The router itself lives on 10.0.1.0/24, but that is not the only subnet that matters. Pi-hole lives at 10.0.0.88, and without access to that subnet, DNS would not work correctly for remote clients. This is where I had to remind myself that routing and DNS are part of the same user experience. Reaching an IP address is not enough if the names I actually use cannot resolve.

On the LXC, I advertised all three home routes and also advertised the router as an exit node:

tailscale set \
  --advertise-routes=10.0.0.0/24,10.0.1.0/24,10.0.2.0/24 \
  --advertise-exit-node

Then I restarted tailscaled:

systemctl restart tailscaled

I like separating the initial join from the route advertisement. First the node joins Headscale. Then I configure what the node should advertise. It makes the workflow easier to reason about, and it also made troubleshooting clearer when I was checking which routes Headscale could see.

After that, I went back to Headscale and listed the routes:

kubectl -n headscale exec deploy/headscale -- \
  headscale routes list

The home routes showed up, and the exit-node routes showed up as well. Advertising them from the router is only the first half. Headscale still needs to enable the routes before clients can use them.

Enabling the routes in Headscale

From the Headscale side, I enabled each route that I wanted to use:

kubectl -n headscale exec deploy/headscale -- \
  headscale routes enable --route <ROUTE_ID>

For the home networks, I enabled:

10.0.0.0/24
10.0.1.0/24
10.0.2.0/24

For the exit node, Headscale may show default routes such as:

0.0.0.0/0
::/0

Those represent the “send all traffic through this node” behavior. Once those routes are enabled, the LXC can be selected as an exit node from a client.

This separation is important. The router can advertise routes, but Headscale decides whether those routes should actually be accepted. That applies to both subnet routes and exit-node routes.

Once the routes were enabled, my remote devices had two options. They could reach only the homelab subnets through the LXC, or they could choose the LXC as an exit node and send all traffic through home.

The setup changed from a simple client network:

laptop -> Headscale -> phone

into something more useful:

laptop -> Headscale network -> tailscale-router -> homelab LAN
laptop -> Headscale network -> tailscale-router -> internet through home

That was the point where the Headscale experiment started feeling like part of the homelab instead of a separate cloud-side project.

Making Pi-hole DNS work remotely

Routing gave my devices a path to the home LAN, but DNS still needed to be correct.

Inside my home network, Pi-hole handles local DNS. It knows records under my internal c.home domain. That is why names like this work when I am at home:

blog.c.home

To make the same names work remotely, Headscale needs to push Pi-hole as the DNS resolver for connected clients.

In my Headscale values, the relevant settings look like this:

HEADSCALE_DNS_MAGIC_DNS: "true"
HEADSCALE_DNS_BASE_DOMAIN: "hanusc.internal"
HEADSCALE_DNS_OVERRIDE_LOCAL_DNS: "true"
HEADSCALE_DNS_NAMESERVERS_GLOBAL: '["10.0.0.99"]'

The key value is the global nameserver:

HEADSCALE_DNS_NAMESERVERS_GLOBAL: '["10.0.0.99"]'

That tells clients to use Pi-hole for DNS while connected to the Headscale network.

This only works because the subnet router also advertises 10.0.0.0/24. If the remote client cannot reach 10.0.0.99, then pushing Pi-hole as the DNS server does not help. The resolver itself has to be reachable through the private route.

The exit node makes this even more useful in some situations. If I choose the home LXC as my exit node, then my device is not only resolving home names through Pi-hole; it is also sending all of its traffic out through the home network. I do not need that all the time, but it is useful to have as an option.

On mobile, there is one more practical detail: the Tailscale app needs to use Tailscale DNS. If the phone keeps using the carrier DNS or Android Private DNS, it may never ask Pi-hole about c.home.

Testing from outside the house

The real test had to happen outside the home network.

When I am at home, blog.c.home already works. That does not prove the subnet router is doing anything. The test is whether the same name works when I am away, connected through the Headscale network.

From outside the home network, I connected my laptop to Tailscale and tested DNS first:

dig blog.c.home

Then I tested HTTPS:

curl -v https://blog.c.home

Finally, I opened the same address in the browser:

https://blog.c.home

That was the moment the setup felt right. I was not using a public hostname, opening a port on my home router, or typing a private IP address from memory. I was using the same homelab domain I use inside the house, but from outside the house.

The path looked like this:

Laptop outside home
        |
        v
Tailscale client
        |
        v
Headscale coordination on OCI K3s
        |
        v
tailscale-router LXC at home
        |
        v
Pi-hole DNS and homelab LAN
        |
        v
blog.c.home

The page loaded, and the internal name worked exactly the way I wanted.

I also tested the exit-node path separately. That part is not required for reaching blog.c.home, but it proves the router can do more than subnet routing. When I select tailscale-router as the exit node from my client, my traffic can leave through the home network. When I do not need that, I can leave exit-node usage off and still use the subnet routes for homelab access.

That distinction is nice. The same LXC gives me both capabilities, but I do not have to use them the same way all the time.

Where this leaves the homelab

This started as a continuation of the Headscale experiment, but it fits better in Homelab Arena because the real result is inside the home network. Headscale on OCI K3s gives me the coordination point. The Proxmox LXC gives me a small router at home. Pi-hole keeps the c.home names working. The regular Tailscale clients connect everything together.

The setup also keeps the homelab private. I am not exposing every internal service just so I can reach it when I am away from home. I am not creating a public endpoint for every dashboard. I am not relying on random IP addresses. Trusted devices join Headscale, use the LXC router, and reach the home network through private routes.

I like that the LXC is not tied to the homelab K3s cluster. It sits closer to the network layer, near pfSense and Pi-hole, which makes more sense for remote access. If I am rebuilding the application cluster, I do not want that to also break my way back into the house. A small, boring router outside the app cluster feels like the cleaner design.

There is still more to do. The next layer is access control: which devices should reach which parts of the homelab, how routes should be limited, and how new clients should be approved. But for this step, the important part works.

When I am away from home, I can connect through Headscale and open:

https://blog.c.home

That is the win.

The homelab stays private, the OCI K3s cluster and homelab K3s cluster now work together, and the LXC router gives me both private subnet access and an optional way to route traffic out through home.