Running a Self-Hosted Tailscale Control Server Behind a Reverse Proxy
Headscale's documentation says not to put it behind a reverse proxy. The homelab does anyway, and the reason is a single Traefik TLS option that strips HTTP/2 from the ALPN list.
The problem
Tailscale is a WireGuard-based overlay network. The clients are open source; the
part that is not is the control server, the thing that exchanges WireGuard public
keys, hands out the 100.64.0.0/10 addresses, decides which devices may talk to
which, and publishes the routes a node advertises. Headscale reimplements that
control server as a single open-source binary, so a self-hosted network does not
phone home to Tailscale’s cloud.
That is the appeal, and it is also the first complication. A control server is, by definition, a server that clients on the public internet must be able to reach: the whole point is that a laptop on a café’s Wi-Fi and a phone on mobile data can join the network from anywhere. So it needs a public hostname, a TLS certificate, and a path through the home firewall. In a homelab that already routes every public hostname through a single Traefik instance, the obvious move is to add one more router for the control server, exactly like every other service.
The obvious move is also the one Headscale’s documentation tells you not to do. The repository’s README says they do not support or encourage running it behind a reverse proxy or in a container. That is fair guidance, and it is also the part of the setup that took the longest to figure out, because the reason is not in the sentence: the protocol the clients speak to the control server is not plain HTTPS.
Tailscale’s clients do not authenticate to the control server with a normal browser-style TLS handshake and a cookie. They use a protocol called noise, a small, explicit key-exchange pattern that sits on top of the connection and does its own handshake. The client opens to the control server, performs the noise handshake, and only then does the authenticated control traffic flow. That handshake is designed to run over a transport that behaves like a simple, full-duplex byte stream. HTTP/1.1 over TLS behaves that way. HTTP/2 does not: it multiplexes many logical streams over one connection, frames them in its own way, and negotiates itself during the TLS handshake via ALPN. A noise client that expects a quiet byte stream does not want the proxy deciding to speak HTTP/2 first.
So the constraint is concrete and unglamorous: the reverse proxy must present the
control server’s hostname over TLS with HTTP/1.1 only. If the proxy offers
h2 in the ALPN negotiation, the client may accept it, the noise handshake
meets an HTTP/2 frame layer instead of the stream it expects, and the connection
fails in a way that is easy to misdiagnose as a certificate problem or a
firewall problem, because the TLS layer itself completes fine.
Working through it
The setup in the homelab is deliberately boring. Headscale runs as a Docker container on the same Mini PC that runs everything else, with a Postgres database for its state and a Headplane container for a web UI. The Mini PC is the box that also runs the Tailscale client itself, advertising the home LAN subnet to the rest of the mesh, which is what makes the internal services reachable from a device that is not on the LAN at all.
services:
headscale:
image: headscale/headscale:0.26.2
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./services/headscale/config:/etc/headscale:ro
- headscale_data:/var/lib/headscale
depends_on:
headscale-db:
condition: service_healthy
networks:
- proxy
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.headscale.rule=Host(`vpn.example.com`)"
- "traefik.http.routers.headscale.entrypoints=websecure"
- "traefik.http.routers.headscale.tls.certresolver=letsencrypt"
- "traefik.http.routers.headscale.tls.options=no-h2@file"
- "traefik.http.services.headscale.loadbalancer.server.port=8080"
- "traefik.docker.network=proxy"
headscale-db:
image: postgres:16
container_name: headscale-db
restart: unless-stopped
environment:
POSTGRES_DB: headscale
POSTGRES_USER: headscale
volumes:
- headscale_db:/var/lib/postgresql
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U headscale -d headscale"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
proxy:
external: true
internal:
name: headscale_internal
internal: true
volumes:
headscale_data:
headscale_db:
Three things in that file are worth pausing on.
First, the internal network is marked internal: true. That is a Docker
network with no route to the outside world. The Postgres database lives on it
and only on it, so even though the container host is on the public internet, the
database has no path to the internet at all. Headscale is on both proxy and
internal: it talks to the database on the internal side and to Traefik on the
proxy side. The control server is the only thing with a public hostname; its
storage is not.
Second, the router rule is Host(vpn.example.com). The control server gets
its own apex-level hostname, not a subdomain of the internal service domain.
That is a deliberate split: the internal services live under one domain that
resolves only inside the mesh, and the control server lives under a hostname
that must resolve from anywhere, because a client on mobile data has to be able
to find it before it is on the network at all.
Third, and the one that matters, the router carries
traefik.http.routers.headscale.tls.options=no-h2@file. That label points at a
named TLS option defined in Traefik’s dynamic configuration:
tls:
options:
no-h2:
alpnProtocols:
- "http/1.1"
The alpnProtocols list is the set of application-layer protocols Traefik will
offer during the TLS handshake for this router. By default Traefik offers both
h2 and http/1.1, so a modern client that supports HTTP/2 will negotiate
h2. The no-h2 option replaces that list with http/1.1 alone. The handshake
still completes, the certificate is still valid, but the transport underneath is
now a plain, full-duplex stream. The noise client gets the byte stream it was
designed for, and the control connection comes up.
The @file suffix matters for a reason that is easy to miss. Traefik’s dynamic
configuration is split between the entrypoint and router labels (which come from
the Docker labels) and the providers for middlewares, TLS options, and the rest
(which come from files or other sources). A TLS option referenced from a Docker
label has to be resolvable, and @file tells Traefik to look for it in the file
provider. Without the suffix, or with the option defined in a provider the
router cannot see, the label is silently ignored and the router falls back to
the default ALPN list, which is exactly the failure mode you are trying to avoid.
The Headscale configuration itself is unremarkable, which is the point. It points at the public hostname, uses the public DERP map for relays, and leaves OIDC to the existing Keycloak realm so that joining the mesh is the same “open this URL and log in” flow as everything else:
server_url: "https://vpn.example.com"
listen_addr: "0.0.0.0:8080"
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
dns:
magic_dns: true
base_domain: "mesh.example.com"
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
split:
"home.example.com":
- 192.168.178.1
oidc:
only_start_if_oidc_is_available: true
issuer: "https://auth.example.com/realms/homelab"
client_id: headscale
scope:
- openid
- profile
- email
Two lines in that file do more than they look like. derp.server.enabled: false
with the public derpmap means the homelab does not run its own DERP relay.
DERP is Tailscale’s relay layer: when two devices cannot find a direct path
through their NATs, they bounce through a relay. Using the public map means the
homelab gets relay coverage for free, at the cost of a little traffic transiting
Tailscale’s servers when a direct connection is not possible. For a personal
network that is a fair trade, and it is the difference between “works when I am
home” and “works when I am on a train.”
The split nameservers entry is the other quiet load-bearer. It tells the mesh
that names under home.example.com resolve to the Mini PC’s LAN address. That
is split DNS: inside the mesh, something.home.example.com points at the real
internal service, and from the public internet the same name either does not
resolve or resolves to nothing useful. Combined with the Mini PC advertising the
192.168.178.0/24 subnet, it is what makes an internal hostname reachable from
a device that is, from the internet’s point of view, not in that subnet at all.
The solution
The fix for the original problem is one label and one small file, and the rest is the mesh being a mesh.
The label is traefik.http.routers.headscale.tls.options=no-h2@file on the
Headscale router. The file is the no-h2 TLS option that restricts ALPN to
http/1.1. Together they make the proxy offer a transport the noise protocol
can actually use. Nothing else about the reverse proxy changes: the same
Let’s Encrypt resolver, the same websecure entrypoint, the same host-based
routing as every other service. The control server is, from Traefik’s point of
view, just another HTTPS hostname with one extra constraint.
The reason it is worth writing down is that the failure is quiet. A client that negotiates HTTP/2 against a noise endpoint does not fail with a TLS error you can read in a log; it fails with the connection simply not establishing, or establishing and then stalling, and the obvious suspects (certificate, port forward, firewall) all check out. The one thing that differs between “works” and “does not” is a protocol version the client picked during handshake, which is the last place you look because the handshake reported success.
The subnet router is the second half of making it a network you use rather than a network you have. The Mini PC runs the Tailscale client as a native systemd service, joins the mesh, and advertises the home LAN:
tailscale up --login-server=https://vpn.example.com \
--advertise-routes=192.168.178.0/24 \
--hostname=minipc
Headscale requires an operator to approve advertised routes, so there is a one-time
step to enable the 192.168.178.0/24 route, and then a one-time
tailscale up --accept-routes on the devices that want to use it. After that,
a laptop on mobile data that is on the mesh can reach 192.168.178.20 and
everything behind the home router, because the Mini PC is carrying that subnet
into the overlay. The route approval is manual and survives re-deploys, which is
a small price for not having a container silently advertise a route it should
not.
The pieces fit together in a way that is easy to state and hard to assemble:
- The control server is public, on its own hostname, behind the same Traefik as everything else, with ALPN pinned to HTTP/1.1 so the noise handshake works.
- Its database is on an internal Docker network with no external route.
- The Mini PC is both a mesh node and a subnet router, pulling the LAN into the overlay.
- Split DNS points the internal service domain at the Mini PC, so internal names resolve only from inside the mesh.
- The public DERP map provides relay fallback, so a device behind a hostile NAT still connects, just slower, instead of not at all.
Conclusion
Headscale’s documentation is not wrong to discourage a reverse proxy: the
control server’s control connection is not a normal HTTPS endpoint, and a proxy
that negotiates HTTP/2 will break it in a way that is quiet and easy to blame on
something else. The homelab runs it behind Traefik anyway, because the control
server needs a public hostname and the rest of the stack already has exactly one
box that hands out public hostnames. The reconciliation is a single TLS option
that removes h2 from the ALPN list on that one router, and the @file suffix
that makes sure the option is actually found.
The broader lesson is that “self-hosted” does not mean “standalone.” The control server is one container in a stack that already has a reverse proxy, a certificate authority, an identity provider, and a database. The interesting part is not that Headscale runs in Docker; it is that a protocol with a specific transport requirement can be satisfied by the proxy you already have, with one named option, without giving the service its own special port or its own special path through the firewall. The mesh then does the rest: a subnet router pulls the LAN in, split DNS keeps internal names internal, and a public relay keeps a device on a bad network connected at all.