The DevOps Odyssey, Part 10: From Tailscale Curiosity to a Self-Hosted Headscale Network
I have always liked the idea behind Tailscale.
Private networking sounds simple until you actually need to run it. VPNs, firewall rules, DNS, split tunnels, routes, certificates, mobile clients, and access control can quickly turn into a project of their own.
Tailscale makes that world feel much more approachable.
Install a client. Log in. Devices find each other. Traffic goes over WireGuard. Suddenly, you have a private mesh network without spending a weekend rebuilding your entire mental model of VPN infrastructure.
That simplicity is powerful.
But as usual, curiosity got me.
At some point, I found Headscale, an open-source, self-hosted implementation of the Tailscale coordination server. What made it more interesting was that Tailscale itself mentions Headscale on its open-source page.
Tailscale describes Headscale as an open-source alternative to the Tailscale coordination server. It is developed independently, but Tailscale also notes that it works with Headscale maintainers when client changes may affect compatibility.
That made the project feel worth exploring.
I was not looking for a “replace Tailscale tomorrow” project. I was more interested in a simpler question:
What does it feel like to self-host the control-plane side of a Tailscale-style network?
So I tried it.

Why I Was Curious
Headscale immediately appealed to the homelab part of my brain.
Tailscale’s model is elegant because the data plane is peer-to-peer where possible. Devices communicate over encrypted WireGuard tunnels, while the coordination server helps them discover each other, authenticate, exchange metadata, and understand how the private network should behave.
In the normal Tailscale product, Tailscale runs that coordination server for you.
Headscale asks a different question:
What if I run that coordination server myself?
That question was interesting for a few reasons.
Running something myself usually helps me understand where the real boundaries are. It turns a polished product experience back into individual pieces I can inspect.
It also fits the homelab mindset. I already run Kubernetes, DNS, GitOps, reverse proxies, monitoring, and internal tools. A self-hosted coordination server felt like a natural next experiment.
Most importantly, it was small enough to test without designing the final architecture upfront.
I did not need to build a full production network on day one. I only needed one server and a couple of clients.
What Headscale Is
In simple terms, Headscale is a self-hosted coordination server for Tailscale clients.
It is not a replacement for the Tailscale client.
That distinction matters.
For this experiment, I still used the regular Tailscale client on my laptop and phone. The difference is that instead of logging into Tailscale’s hosted coordination service, the clients point to my own Headscale server.
The simplified flow looks like this:
Tailscale client
↓
Custom login server URL
↓
Headscale
Headscale handles the coordination side. The clients still use the Tailscale client software.
That distinction is important because Headscale is not the full Tailscale SaaS experience. I do not see it as a drop-in replacement for every Tailscale use case, and I do not think self-hosting is automatically better.
For me, Headscale is best viewed as a self-hosted control server experiment for personal labs, homelabs, and small environments.
That made it a good fit for the kind of experiment I like writing about in this series.
Running It on K3s
At this point in the DevOps Odyssey, deploying something to K3s is almost part of the game.
I already had the cluster, Traefik, persistent storage, GitOps, and a public hostname wired up from earlier parts of the series. So when I wanted to try Headscale, creating a separate VM did not feel necessary.
The obvious move was to deploy it like any other platform service.
The requirements were simple:
- run Headscale as a container
- expose it through HTTPS
- persist its state
- make it reachable from a public hostname
- connect clients using the regular Tailscale app
The most important setting was the public server URL:
HEADSCALE_SERVER_URL: "https://janus-headscale.duckdns.org"
That is the address my Tailscale clients use as their custom login server URL.
Everything else was familiar territory: Helm values, Traefik ingress, a persistent volume, and a node selector to keep it on the system node.
In other words, the interesting part was not “can I run another container on K3s?”
The interesting part was whether this container could become the coordination server for my own small Tailscale-style network.
The Small First Goal
I kept the first goal intentionally small.
I was not trying to solve ACLs, subnet routing, MagicDNS, OIDC, or a web UI yet. I only wanted to answer one question:
Can I deploy Headscale and connect my laptop and phone?
That was enough for the first milestone.
Two clients. One control server. Successful registration. Visible nodes.
If that worked, I could build on it later.
High-Level Architecture
The first version looked like this:
+----------------------+ +--------------------------------+
| MacBook / Laptop | | |
| Tailscale client |<---------------->| |
+----------------------+ | |
Coordination via custom | |
login server | |
| Headscale |
+----------------------+ | OCI / K3s |
| Android Phone | | janus-headscale.duckdns.org |
| Tailscale client |<---------------->| |
+----------------------+ | |
Coordination via custom | |
login server +--------------------------------+
In this first post, only the laptop, phone, and Headscale server are in scope.
The homelab router, subnet routes, MagicDNS, ACLs, and private service access are the next layer. I wanted the control server to work first.
No UI, At Least Not Yet
One thing that surprised me at first was that Headscale does not ship with a built-in admin UI.
Management is mostly done through the CLI.
headscale users list
headscale nodes list
headscale preauthkeys create
In my deployment, the Headscale container image is also fairly hardened. It is not the kind of image where I casually kubectl exec -it into a shell and poke around.
So I treated the Headscale CLI as the interface and ran specific commands through Kubernetes:
kubectl -n headscale exec deploy/headscale -- \
headscale users list
That became the operational pattern.
Run a specific command. Get the result. Avoid treating the pod like a pet server.
Registering Devices
Before any device could join, I needed a Headscale user.
I created one called janus:
kubectl -n headscale exec deploy/headscale -- \
headscale users create janus
Then I generated a short-lived pre-auth key for that user:
kubectl -n headscale exec deploy/headscale -- \
headscale preauthkeys create \
--user janus \
--expiration 1h
Because I was not using OIDC yet, pre-auth keys were the simplest way to register devices. I kept the key short-lived so it was useful for setup without becoming a permanent credential.
The basic loop was simple:
create user
generate short-lived key
register device
list nodes
For this first version, that was enough.
Connecting My Laptop and Phone
On my laptop, I used the regular Tailscale client.
The difference was the login server.
Instead of using Tailscale’s hosted coordination server, I pointed the client to:
https://janus-headscale.duckdns.org
The command looked like this:
tailscale up \
--login-server https://janus-headscale.duckdns.org \
--authkey <KEY>
After that, the laptop appeared in Headscale:
kubectl -n headscale exec deploy/headscale -- \
headscale nodes list
That was the first satisfying moment.
The control plane was mine. The client was still the regular Tailscale client. The node registered successfully.
My phone was the next test. I installed the regular Tailscale Android app, configured it to use the same alternative server, and registered it with another pre-auth key.
After that, the phone also appeared in the node list.
At this point, the experiment had crossed the first threshold:
Headscale on OCI/K3s was coordinating real clients.
The laptop and phone were no longer just independent devices. They were part of the same self-hosted tailnet-style network.
What Worked
The core experiment worked.
Headscale ran on K3s. Traefik exposed it over HTTPS. The regular Tailscale client could connect to it. My laptop joined. My Android phone joined. The nodes appeared in Headscale.
That was enough to prove the basic model.
The smallest useful version was surprisingly small:
Headscale
K3s
Ingress
Persistent volume
One user
Short-lived pre-auth keys
Tailscale clients
That is a good sign.
It means the project is approachable enough for a homelab experiment, while still exposing enough of the system to learn how the pieces fit together.
What I Learned
The biggest thing I learned is that “Tailscale” is not just one piece of software in my head anymore.
Before trying Headscale, I mostly thought about Tailscale as a complete experience: install the client, log in, and devices can talk to each other. Running Headscale made the boundaries much clearer.
There is the client on each device. There is the coordination server. There is the encrypted WireGuard traffic between peers. Then there are the supporting pieces: DNS, routes, ACLs, identity, and device registration.
Tailscale’s hosted product makes those pieces feel almost invisible. Headscale exposes one of the important layers and lets me run it myself.
That is useful, but it also changes the responsibility model.
If I self-host the coordination server, I own it. I need to think about persistence, upgrades, backups, access, and recovery. Self-hosting is not magic. It is ownership. I get more control, but I also own the persistence, upgrades, backups, access, and recovery story.
Final Thoughts
I started this because I was curious.
I already liked the way Tailscale made private networking feel simple, and discovering Headscale made me want to understand what the self-hosted side of that model looked like.
After running it on my existing K3s cluster and connecting my laptop and phone, the first experiment worked.
I now have a small self-hosted coordination server using the regular Tailscale clients, short-lived auth keys, and a public endpoint I control.
That is enough for a first step.
What I am curious about next is whether I can use this setup to connect back into my homelab services safely, without exposing them directly to the public internet.
That will be the next experiment.