Skip to main content

Remote access for you and your family with Headscale

HomelabGuide

This is the setup I ended up with after a first Tailscale attempt kept failing at random. Every value below was tested end to end, including from outside the home network. Replace example.com, the IP addresses and the user names with your own.

What each person gets​

WhoReachesHow
Admin (you)every tailnet device and the home LAN 192.168.1.0/24home server is a subnet router, split DNS to your home resolver
Familyhttps://jellyfin.example.com:8443, https://immich.example.com:8443, https://nextcloud.example.com:8443extra DNS records point those names at the home server's tailnet IP

Family devices get no route into the home LAN at all. That matters at their own house too: a pushed LAN route would send their local traffic into your tunnel.

How it fits together​

  • VPS (public IP): Headscale behind nginx on https://headscale.example.com, the embedded DERP relay on UDP 3478, and Headplane as the web UI on port 3000, reachable over the tailnet only.
  • Home server (tag tag:homeserver): advertises the home LAN, runs the DNS resolver and the reverse proxy. The proxy has two entrypoints: :443 for every app and :8443 for the family apps only.
  • Devices: the official Tailscale apps, pointed at your own control server.

BeforeA VPS with a public IPv4 (and ideally IPv6) address, Docker, nginx and certbot. A home server that already runs your apps behind a reverse proxy with a wildcard certificate.

1. Create the DNS record first​

Create headscale.example.com as an A (and AAAA) record to the VPS.

DNS only, no proxy

Headscale cannot sit behind the Cloudflare proxy or a Cloudflare Tunnel. The Tailscale control protocol upgrades the connection with a POST request, and Cloudflare does not pass that through. Keep the record DNS only.

Create the record before anything queries the name. A resolver that already asked will cache the "does not exist" answer for the zone's negative TTL, often 30 minutes. If that happens, flush the resolver cache instead of waiting.

If your home resolver rewrites *.example.com to the home server, add an exception for headscale.example.com that returns the real public record. Otherwise devices at home talk to the wrong machine.

2. Run Headscale and Headplane​

/opt/headscale/compose.yaml:

services:
headscale:
image: docker.io/headscale/headscale:0.29.4
container_name: headscale
restart: unless-stopped
command: serve
read_only: true
tmpfs: [/var/run/headscale]
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9090:9090"
- "3478:3478/udp"
volumes:
- ./config:/etc/headscale:ro
- ./lib:/var/lib/headscale
- ./dns:/etc/headscale-dns
healthcheck:
test: ["CMD", "headscale", "health"]
headplane:
image: ghcr.io/tale/headplane:0.7.1
container_name: headplane
restart: unless-stopped
ports:
- "127.0.0.1:3001:3000"
volumes:
- ./headplane/config.yaml:/etc/headplane/config.yaml:ro
- ./headplane/cookie_secret:/etc/headplane/cookie_secret:ro
- ./headplane/data:/var/lib/headplane
- ./config/config.yaml:/etc/headscale/config.yaml:ro
- ./dns:/etc/headscale-dns

Pin exact versions. Headscale refuses to skip a minor version on upgrade, so you will want to know which one you run.

The Headscale config is mounted read-only into Headplane on purpose. Headplane then shows the settings but cannot rewrite the file. Config and policy changes go through the files, where you can review them.

Headplane config
server:
host: "0.0.0.0"
port: 3000
base_url: "http://100.64.0.2:3000" # the VPS tailnet IP
cookie_secret_path: "/etc/headplane/cookie_secret" # 32 characters: openssl rand -hex 16
cookie_secure: false # plain HTTP inside the WireGuard tunnel
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale:8080"
public_url: "https://headscale.example.com"
config_path: "/etc/headscale/config.yaml"
dns_records_path: "/etc/headscale-dns/extra-records.json"
integration:
agent: {enabled: false}
docker: {enabled: false}

Leave out the kubernetes block entirely. If it is present, Headplane 0.7.1 refuses to start until pod_name is set.

3. Put nginx in front​

The two details that matter are the connection upgrade and long timeouts. The admin UI never goes on the public name.

# $connection_upgrade: map $http_upgrade { default upgrade; "" close; }
server {
listen 443 ssl;
server_name headscale.example.com;
# ssl_certificate lines from certbot
location /admin { return 404; }
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
server {
listen 3000; # Headplane, tailnet only
allow 100.64.0.0/10;
allow fd7a:115c:a1e0::/48;
deny all;
location / { proxy_pass http://127.0.0.1:3001; }
}

Open the firewall for STUN and for Headplane on the tailnet interface only:

ufw allow 3478/udp
ufw allow in on tailscale0 to any port 3000 proto tcp
note

The http2 on; directive needs nginx 1.25.1 or newer. Ubuntu 24.04 ships 1.24, where nginx -t rejects it. Leave it out.

4. Choose the Headscale settings that prevent surprises​

Start from the config-example.yaml of your exact version and change these keys:

KeyValueWhy
server_urlhttps://headscale.example.comwhat clients connect to
node.expiry0devices never expire, so nothing silently drops off after six months
derp.server.enabledtrue, plus ipv4/ipv6 of the VPSyour own relay. Keep the Tailscale DERP map URL as a backup
dns.base_domaintailnet.internalmust not sit under a domain your home resolver rewrites
dns.override_local_dnsfalseTailscale never takes over a device's normal DNS
dns.nameservers.splitexample.com: [100.64.0.1]your app names resolve through the home resolver over the tailnet
dns.extra_records_path/etc/headscale-dns/extra-records.jsonfamily app names answer the home server's tailnet IP
policy.path/etc/headscale/policy.hujsonaccess rules as a file

Extra records only take effect for a name when a split DNS route covers its domain. That is why the split entry above matters even for family devices.

extra-records.json:

[
{"name": "immich.example.com", "type": "A", "value": "100.64.0.1"},
{"name": "jellyfin.example.com", "type": "A", "value": "100.64.0.1"},
{"name": "nextcloud.example.com", "type": "A", "value": "100.64.0.1"}
]

Headscale reloads this file by itself.

5. Write the policy with tests​

{
"groups": {
"group:admin": ["alice@"],
"group:family": ["bob@"],
},
"tagOwners": {
"tag:homeserver": ["group:admin"],
"tag:vps": ["group:admin"],
},
"hosts": { "home-lan": "192.168.1.0/24" },
"autoApprovers": { "routes": { "192.168.1.0/24": ["tag:homeserver"] } },
"grants": [
{ "src": ["group:admin"], "dst": ["*", "home-lan"], "ip": ["*"] },
{ "src": ["group:family"], "dst": ["tag:homeserver"], "ip": ["tcp:8443", "udp:53", "tcp:53"] },
],
"tests": [
{
"src": "bob@",
"accept": ["tag:homeserver:8443"],
"deny": ["tag:homeserver:443", "tag:homeserver:22", "tag:vps:3000", "192.168.1.1:80"],
},
{
"src": "alice@",
"accept": ["tag:homeserver:443", "tag:vps:3000", "192.168.1.1:80"],
},
],
}
* changed in Headscale 0.29

Since 0.29, * means tailnet addresses only. LAN subnets behind a subnet router must be named explicitly, here as home-lan. Policies written for older versions can lose LAN access after an upgrade.

Apply and check:

docker exec headscale headscale policy check -f /etc/headscale/policy.hujson
docker kill -s HUP headscale
docker logs --since 1m headscale | grep -i policy

A failing test rejects the reload and keeps the old policy. A test that names a user without any device fails with "resolved to no IP addresses". So add a user's test entry after their first device has joined.

6. Give the reverse proxy a family-only door​

With Traefik, add a second entrypoint and attach only the family routers to it. Requests for any other host on :8443 get a 404, because no router there matches.

# traefik.yml (static)
entryPoints:
shared:
address: ":8443"
http:
tls:
certResolver: letsencrypt
# dynamic config, for jellyfin, immich and nextcloud only
http:
routers:
jellyfin:
rule: Host(`jellyfin.example.com`)
entryPoints: [websecure, shared]
service: jellyfin

Publish 8443:8443 on the Traefik container and recreate it. Jellyfin, Immich and Nextcloud all keep the port in their redirects.

7. Enroll the home server and the VPS​

Create users first: docker exec headscale headscale users create alice, then the same for bob.

On the home server:

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
sudo tailscale up --login-server=https://headscale.example.com \
--advertise-tags=tag:homeserver --advertise-routes=192.168.1.0/24 \
--accept-dns=false --hostname=homeserver

It prints a URL ending in hskey-authreq-.... Approve it on the VPS. Ownership passes to the tag because alice owns it.

docker exec headscale headscale auth register --user alice --auth-id hskey-authreq-...

The route is approved by autoApprovers. Do the same on the VPS with --advertise-tags=tag:vps and no routes. Keep --accept-dns=false on both servers: the home server is your DNS resolver, and the VPS runs the control plane.

8. Enroll the devices​

DeviceHow
iPhone, iPadTailscale app, account icon, Log in…, options menu, Use custom coordination server. Do not sign in with Apple or Google first
Macstandalone app, hold Option and click the menu-bar icon, Debug, Custom Login Server, Add Account…
Linuxsudo tailscale up --login-server=https://headscale.example.com

Each one shows an hskey-authreq-... code. Approve it with the right user (alice for yours, bob for family). Headplane can do the same under Add Device, Register Machine Key.

On your own phone and laptop, turn on VPN On Demand with Wi-Fi set to Except On your home network. At home the device then uses the LAN directly. Linux machines that live on the home LAN should keep --accept-routes=false.

Family iPads can use Always. They get no LAN route, so the tunnel cannot interfere with the local network wherever they are.

9. Prove it from outside​

Enroll a throwaway device as the family user from a container on the VPS, which sits outside your home like a family device would:

docker run -d --name family-test --rm --cap-add NET_ADMIN --device /dev/net/tun \
-e TS_USERSPACE=false -e TS_ACCEPT_DNS=true -e TS_STATE_DIR=/tmp/ts \
-e TS_EXTRA_ARGS="--login-server=https://headscale.example.com --hostname=family-test" \
tailscale/tailscale:v1.102.4
docker logs family-test 2>&1 | grep -o 'hskey-authreq-[A-Za-z0-9_-]*'

Approve it as bob, then check:

Expected results

Keep the test device until a real family device has joined, because the policy tests need that user to own a device.

Back it up​

The SQLite database and the noise and DERP keys are the whole identity of the tailnet. With them, every device reconnects after a rebuild. Without them, every device must be enrolled again. Take a consistent snapshot every night:

sqlite3 /opt/headscale/lib/db.sqlite ".backup '/tmp/hs-db.sqlite'"
tar -czf /root/backups/headscale-$(date +%F).tgz -C /opt/headscale \
config dns lib/noise_private.key lib/derp_server_private.key -C /tmp hs-db.sqlite

Copy the archives off the VPS now and then.

When it misbehaves​

SymptomCause and fix
Tailscale works sometimes, then notA DNS blocklist blocks tailscale.com. HaGeZi's "Encrypted DNS/VPN/TOR/Proxy Bypass" list does. If clients get two resolvers and only one blocks, the failure looks random. Allow @@||tailscale.com^ and @@||tailscale.io^
Devices at home cannot reach the control serverA wildcard DNS rewrite for your domain catches the control server name. Add an exception
The control server name is "not found" right after creating itNegative caching in your resolver. Flush its cache
LAN devices unreachable from a phone at homeThe phone routes the LAN through the subnet router. Use VPN On Demand with Except On home Wi-Fi
A policy edit is rejectedRead the test message. "resolved to no IP addresses" means a user in the tests has no device yet
The home server warns about IPv6 forwarding or UDP GROHarmless when you advertise only an IPv4 route

Next step: Isolated analysis network