GuidesNetwork and VPNHeadscale for Tailscale

Run your own Tailscale control server with Headscale

Headscale on Debian 13, the open-source control server for Tailscale's apps, in rootless Podman under a user of its own, with its own relay, so your private network runs without Tailscale's servers.

Tested on Headscale 0.29.4 and Tailscale 1.102 on Debian 13 (trixie) on a Melonslab server Updated September 26, 2026

Recommended server for this guide

VC-S Micro · 2 vCPU · 8 GB Memory · 250 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€7.99/mo

Deploy now
On this page

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.com pointing 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_proxies lets Headscale log each device's real address, which Caddy passes on.
  • prefixes are the address ranges Tailscale normally uses for tailnets.
  • derp switches 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, list https://controlplane.tailscale.com/derpmap/default under urls instead.
  • dns gives every device a name, such as laptop.tail.example.com. base_domain must not be example.com itself, because the server's own name is under it, so a subdomain like tail.example.com is 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.

Run it on your own server

VC-S Micro

€7.99/mo

vCPU
2
Memory
8 GB
Storage
250 GB
Transfer
10 TB
Standard
HDD · RAID 10
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides