What you will set up
Tailscale joins your devices into one private network, a tailnet, wherever each of them is. Devices connect to each other directly, encrypted with WireGuard, and each gets a fixed address and a name. A control server keeps the list of devices and hands out their keys. Normally Tailscale Inc. runs it, and relays traffic for devices that cannot reach each other directly.
Headscale is an open-source control server you run yourself. The Tailscale apps work with it as they are: you only point them at your server. The list of your devices stays on your server, and so does the relay, so no part of your tailnet goes through Tailscale's servers.
Here it runs as one container, under a user of its own called headscale, behind Caddy from the Podman guide. Only the relay's address check, STUN, is open to the internet directly.
Every step below was run on a Melonslab server with Debian 13:
- Three Tailscale 1.102 clients on Linux joined: two with a key, and one through the login link, as phones and laptops do.
- A client at home reached one on the server directly, and through the server's own relay when UDP between them was blocked.
- STUN reported the home client's real public address, and device names resolved with MagicDNS.
- Headscale logged each client's real address, and everything came back after a reboot.
Headscale used about 17 MB of memory. The steps for phones, Windows and macOS follow Headscale's own documentation.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running;
- an A record and an AAAA record for
headscale.example.compointing at your server; - the Tailscale app on each device, version 1.80 or newer, from tailscale.com/download.
The examples use headscale.example.com, 203.0.113.10 for your server's IPv4 address and 2001:db8:1f::a for its IPv6 address. ip -brief address show eth0 shows yours. Replace them throughout.
Headscale is a tailnet for your devices to reach each other. To browse the internet through your server instead, see the WireGuard guide.
1. Open the STUN port
Devices ask the relay on UDP port 3478 which public address they appear from, which helps them connect directly. As root, if you use ufw as in the security guide:
ufw allow 3478/udp
coturn from the calls guide also uses port 3478. If you run it, use 3479 here and in steps 3 and 4 instead.
2. Create the user
useradd -m -s /bin/bash headscale
loginctl enable-linger headscale
machinectl shell headscale@
Everything up to step 5 runs as headscale.
3. Write the configuration
mkdir -p ~/headscale ~/.config/containers/systemd
Create ~/headscale/config.yaml:
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
# Caddy's connections reach the container from the server's own IPv4 address.
trusted_proxies:
- 203.0.113.10/32
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp:
server:
enabled: true
region_id: 999
region_code: headscale
region_name: Headscale
stun_listen_addr: ":3478"
private_key_path: /var/lib/headscale/derp_server_private.key
ipv4: 203.0.113.10
ipv6: 2001:db8:1f::a
# Only this server relays traffic, not Tailscale's public relays.
urls: []
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
dns:
magic_dns: true
base_domain: tail.example.com
nameservers:
global:
- 9.9.9.9
- 149.112.112.112
- 2620:fe::fe
- 2620:fe::9
What the parts do:
trusted_proxieslets Headscale log each device's real address, which Caddy passes on.prefixesare the address ranges Tailscale normally uses for tailnets.derpswitches on Headscale's relay. It carries traffic between devices that cannot connect directly, still encrypted end to end.urls: []leaves Tailscale's public relays out, so your server is the only relay. To keep theirs as a fallback, listhttps://controlplane.tailscale.com/derpmap/defaultunderurlsinstead.dnsgives every device a name, such aslaptop.tail.example.com.base_domainmust not beexample.comitself, because the server's own name is under it, so a subdomain liketail.example.comis used. Lookups for other names go to Quad9.
4. Describe the container
Create ~/.config/containers/systemd/headscale.container:
[Unit]
Description=Headscale, a Tailscale control server
[Container]
ContainerName=headscale
Image=docker.io/headscale/headscale:0.29
Exec=serve
ReadOnly=true
Tmpfs=/var/run/headscale
Volume=%h/headscale/config.yaml:/etc/headscale/config.yaml:ro
Volume=headscale-data:/var/lib/headscale
# Control and relay, reached through Caddy only
PublishPort=127.0.0.1:8091:8080
# STUN, open to the internet
PublishPort=3478:3478/udp
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Start it:
systemctl --user daemon-reload
systemctl --user start headscale
podman logs headscale
The log shows stun server started at [::]:3478 and listening and serving HTTP on: 0.0.0.0:8080. A warning that it is listening without TLS is expected: Caddy handles HTTPS.
5. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add these two blocks to the end of ~/Caddyfile:
http://headscale.example.com {
# Tailscale checks for captive portals here
handle /generate_204 {
respond 204
}
handle {
redir https://{host}{uri}
}
}
headscale.example.com {
reverse_proxy 127.0.0.1:8091 {
header_up True-Client-IP {remote_host}
header_up X-Real-IP {remote_host}
}
}
The first block answers the check Tailscale makes to find out whether a Wi-Fi network is holding traffic back until you log in, and sends everything else to HTTPS. The two header_up lines replace any address a device claims for itself with the one Caddy sees, so nobody can fake theirs in Headscale's log.
Restart Caddy with systemctl --user restart caddy, and check from any computer:
curl https://headscale.example.com/health
It answers {"status":"pass"}.
6. Create a user
Go back to root with exit, then to machinectl shell headscale@. Every device belongs to a user. Create one, and list the users to see its ID:
podman exec headscale headscale users create anna
podman exec headscale headscale users list
7. Connect your devices
Each app opens a page on your server with a command to run. Run it as the headscale user, with USERNAME replaced by the user from step 6, anna here:
podman exec headscale headscale auth register --auth-id hskey-authreq-... --user anna
It answers Node ... registered, and the device joins. How to start from each app:
- Linux:
sudo tailscale up --login-server https://headscale.example.com, and open the link it prints. - Windows: in PowerShell,
tailscale login --login-server https://headscale.example.com. - macOS:
tailscale login --login-server https://headscale.example.com, or hold Option, click the Tailscale icon, and under Debug, Custom Login Server, choose Add Account. - Android: open the settings menu at the top right, then Accounts. In the three-dot menu, choose Use an alternate server, and enter
https://headscale.example.com. - iPhone and iPad: tap the account icon at the top right and Log in…. In the options menu, choose Use custom coordination server, and enter
https://headscale.example.com.
https://headscale.example.com/apple and /windows show the same steps for those systems.
For a server, or any device without a browser, make a key instead. --user takes the ID from users list:
podman exec headscale headscale preauthkeys create --user 1
The key starts with hskey-, and works once within an hour. On the device:
sudo tailscale up --login-server https://headscale.example.com --authkey hskey-...
8. Check it
On any connected device:
tailscale status
tailscale ping laptop
tailscale netcheck
tailscale status lists every device with its address. tailscale ping answers pong from laptop (100.64.0.1) via 203.0.113.10:41641 or similar for a direct connection, or via DERP(headscale) when the traffic goes through your relay. tailscale netcheck shows Nearest DERP: Headscale, and your public IPv4 address under IPv4, as STUN saw it.
Devices reach each other by name, laptop.tail.example.com, as long as the app is allowed to set DNS, which it is by default. Every device can reach every other device on the tailnet, which suits one person or a family. To limit who reaches what, write a policy, as described in Headscale's documentation.
9. Keep it up to date
As headscale, switch on Podman's daily updates:
systemctl --user enable --now podman-auto-update.timer
The 0.29 tag gets every fix within Headscale 0.29. Headscale must be updated one minor version at a time, 0.29 to 0.30 and so on, without skipping any. Before each, read its release notes, back up as in step 10, then change the tag.
10. Back up
As headscale:
mkdir -p ~/backup
systemctl --user stop headscale
podman volume export headscale-data --output ~/backup/headscale-data.tar
cp ~/headscale/config.yaml ~/backup/
systemctl --user start headscale
That saves the database, with every user and device, and the server's private keys. Copy ~/backup to another machine afterwards, and keep it private.
Troubleshooting
Headscale stops at start with server_url cannot be part of base_domain. base_domain in step 3 contains the server's own name. Use a subdomain, such as tail.example.com.
tailscale netcheck shows UDP: false, or no Nearest DERP. The STUN port is not reachable. Check the ufw rule from step 1, and that the port is the same in steps 1, 3 and 4.
A device stays offline after the login page. The auth register command has not been run, or ran with a user that does not exist. podman exec headscale headscale nodes list shows every registered device.