Headscale with Docker: a Self-Hosted Tailscale Server (Linux server, Windows and Mac clients)
Before you start
- 20 min read
Boxes marked Run in terminal are what you type; plain text explains why. Check what you need first

What you get in one minute#
Headscale is a free, open source control server for the official Tailscale apps: you run the sign-in and key exchange on your own server, and your laptops, phones and servers join a private network without a Tailscale account.

Logo: the official Headscale project (github.com/juanfont/headscale)
Your devices still use the normal Tailscale apps and WireGuard. Headscale only hands out keys and addresses (100.64.x.x); your traffic goes from device to device. It suits a home lab, a family or a small team. It is a community project, not associated with Tailscale Inc., and covers one network (one tailnet).
| What | Value (official docs, read October 2026) |
|---|---|
| Current version | Headscale v0.29.7, released 30 October 2026 (a security release) |
| Image to pin | ghcr.io/juanfont/headscale:0.29.7 (checked on the registry; amd64 and arm64) |
| Time needed | About 30 minutes with a domain ready (our estimate) |
| Size and RAM | Image 113 MB; 13 MiB RAM idle with one device (we measured it, see Performance) |
| Ports | TCP 443 public (HTTPS); TCP 80 only for Let's Encrypt; 8080 stays on the server itself |
| Licence | BSD 3-Clause (LICENSE at the release tag) |
| Clients | Official Tailscale apps. Minimum client v1.80.0; the release notes ask for v1.104.4 or newer |
| Official docs | headscale.net and the repository |
At a glance:
- What it replaces: the Tailscale company's sign-in server. The apps stay the same; you point them at your own address.
- What you need: a Linux server with a public IP, a domain name and HTTPS on port 443.
- Docker is community-supported: the project builds the image but says it does not officially support Docker or reverse proxies. We follow its container page and mark where we went beyond it.
- No web interface: you manage it with a command line tool inside the container.
- Not supported: the docs list Tailscale Funnel, Serve and network flow logs as missing.
- Privacy: by default Headscale tells clients to switch off log upload to Tailscale Inc. Relays default to Tailscale's public DERP servers; your traffic stays encrypted.
How it fits together#
Devices talk to Headscale through your HTTPS address to sign in and fetch keys. After that they talk to each other directly, and use a relay only when a direct path fails.
Drawn for this guide from the official docs and config example. The dashed relay is used only when no direct path works.
Before you start#
You need a Linux server you manage, Docker with Compose, and a domain name that points to it.
Linux server: a public IPv4 address, Docker Engine with Docker Compose, curl, and an x86_64 or arm64 CPU. The docs ask for "a reasonably modern Linux or BSD based operating system".
Windows: a client only (Windows 10 or later). Check the package manager:
winget --version
Mac: a client only (macOS Ventura 13.0 or later, Tailscale's requirement). Check Terminal works:
sw_vers -productVersion
Checklist:
- [ ] Docker and Docker Compose on the server (
docker --version,docker compose version) - [ ] A domain name, for example
headscale.example.com, with a DNS record pointing at the server - [ ] Ports 80 and 443 reach the server (forward them on your router if it sits at home)
- [ ] A second name for the tailnet that is NOT under that domain, for example
tailnet.example.net(Step 2 explains why) - [ ] Nothing else listening on ports 80 and 443 (or an existing Traefik, see the HTTPS section)
Tested by DIY Lab Hub on 2026-10-31: the container, Caddy with tls internal and one Tailscale 1.104.4 test client ran on a Docker Desktop host (Windows 11, Linux containers). We did not test a real Linux server, Let's Encrypt, Traefik, or Windows, Mac and phone clients. Those steps follow the official docs.
Step 1: Create the folders on the Linux server#
Headscale needs a config folder and a data folder (keys and the SQLite database), in ~/docker_volumes/headscale.
Linux#
mkdir -p ~/docker_volumes/headscale/{config,data,backups}
cd ~/docker_volumes/headscale
docker --version
docker compose version
Windows (PowerShell)#
Not supported as the server. The docs name Linux and BSD systems and a public IP address. Run this step on a Linux machine.
Mac (Terminal)#
Not supported as the server. The docs do not list macOS as a host, and we did not test it. Run this step on a Linux machine.
Check it worked. The folders exist:
ls -d ~/docker_volumes/headscale/*
Tip: Install Docker from the official Docker documentation (docs.docker.com/engine/install). Skip install scripts from blogs.
Step 2: Download the config for this version and edit four lines#
Headscale will not start without a config file. The project says to download the example for the same tag as your version.
First replace the two example names in the commands below, headscale.example.com and tailnet.example.net, with your own.
Linux#
cd ~/docker_volumes/headscale curl -fsSL -o config/config.yaml \ https://raw.githubusercontent.com/juanfont/headscale/v0.29.7/config-example.yaml sed -i \ -e 's|^server_url: http://127.0.0.1:8080$|server_url: https://headscale.example.com|' \ -e 's|^listen_addr: 127.0.0.1:8080$|listen_addr: 0.0.0.0:8080|' \ -e 's|^ base_domain: example.com$| base_domain: tailnet.example.net|' \ -e 's|^disable_check_updates: false$|disable_check_updates: true|' \ config/config.yaml grep -n -E '^(server_url|listen_addr|disable_check_updates):|^ base_domain:' config/config.yaml
What each line does:
| Line | Why we change it |
|---|---|
server_url | The address every device uses: https:// and reachable from outside. The example's http://127.0.0.1:8080 only works on the server |
listen_addr | Inside a container 127.0.0.1 hides the port from Docker, so use 0.0.0.0:8080 (the example's own comment says so for production) |
base_domain | The tailnet name (laptop.tailnet.example.net). It must NOT be a parent of the server_url host, or Headscale refuses to start |
disable_check_updates | Stops the startup check against GitHub, which in 0.29.7 slows every command when GitHub is unreachable (issue #3639) |
Windows (PowerShell)#
Not supported as the server. Edit this file on the Linux server, for example over SSH.
Mac (Terminal)#
Not supported as the server. Edit this file on the Linux server, for example over SSH.
Check it worked. The grep prints four lines with your names. To see the base-domain rule yourself, we left base_domain: example.com in place with server_url: https://headscale.example.com. The start failed with server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable.
Tip:
base_domainandserver_urlmust be different names. If your login page isheadscale.example.com, do not useexample.comas the tailnet name.
Step 3: Write the Compose file and start Headscale#
This follows the project's container page, with our changes: a pinned tag, /tmp as a RAM disk because Docker does not add one to a read-only container (issue #3463), and a shared proxy network for the HTTPS step. The port stays on the server's loopback address.
Linux#
cd ~/docker_volumes/headscale
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
cat > docker-compose.yml << 'EOF'
services:
headscale:
image: ghcr.io/juanfont/headscale:0.29.7
container_name: headscale
restart: unless-stopped
read_only: true
tmpfs:
- /var/run/headscale
- /tmp
command: serve
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./config:/etc/headscale:ro
- ./data:/var/lib/headscale
networks:
- proxy
healthcheck:
test: ["CMD", "headscale", "health"]
networks:
proxy:
external: true
EOF
docker compose up -d
docker compose ps
curl -s http://127.0.0.1:8080/health
Windows (PowerShell)#
Not supported as the server. Run this step on the Linux server.
Mac (Terminal)#
Not supported as the server. Run this step on the Linux server.
Check it worked. In our run the container showed (healthy) after under a minute and /health answered:
{"status":"pass"}
The log ends with listening and serving HTTP on: 0.0.0.0:8080. It also warns listening without TLS but ServerURL does not start with http://. The docs say that warning is expected when a proxy handles TLS.
Put it behind HTTPS (with or without Traefik)#
Unlike most apps, Headscale needs HTTPS before your devices can use it: the docs call HTTPS on port 443 "strongly recommended". Pick one route; both need your domain pointing at the server.
The docs' reverse-proxy page is community-written. It says the proxy must pass WebSockets (an upgrade with a POST and the header Upgrade: tailscale-control-protocol), and that Headscale does not work behind Cloudflare's proxy or tunnel.
With Traefik#
Use this if Traefik v3 with the Docker provider already runs on the server. The docs have no Traefik example, so this follows our other guides. It is untested with a real Tailscale login; if sign-in fails, try the Caddy route.
In docker-compose.yml, add this block under the headscale service, after healthcheck:. This assumes the external network proxy and an entrypoint called https (use your own names):
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)"
- "traefik.http.routers.headscale.entrypoints=https"
- "traefik.http.routers.headscale.tls=true"
- "traefik.http.services.headscale.loadbalancer.server.port=8080"
You may now remove the ports: lines from the Headscale service, because Traefik reaches it on the proxy network. We keep the loopback port for the health check from the server. Then docker compose up -d.
Check it worked. curl -I https://headscale.example.com/health returns an HTTP status line over HTTPS.
Without Traefik: Caddy#
Choose this if you have no Traefik. Caddy gets and renews its own certificate.
| Case | What happens |
|---|---|
| Public domain | Caddy gets a Let's Encrypt certificate by itself. The DNS record must point at the server and ports 80 and 443 must reach it |
| Home or lab only | tls internal uses Caddy's own local certificate. Every device must trust Caddy's root certificate first |
Create the Caddy folder and file. The image is caddy:2.11.7, the latest Caddy release read in October 2026:
mkdir -p ~/docker_volumes/caddy/{data,config}
cd ~/docker_volumes/caddy
cat > docker-compose.yml << 'EOF'
services:
caddy:
image: caddy:2.11.7
container_name: caddy
restart: unless-stopped
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data:/data
- ./config:/config
networks:
- proxy
networks:
proxy:
external: true
EOF
The Caddyfile for a public domain (the Headscale container is named headscale, so Caddy finds it by that name):
headscale.example.com {
reverse_proxy headscale:8080
}
For a home-only name use tls internal:
headscale.home.arpa {
tls internal
reverse_proxy headscale:8080
}
The docs' Caddy example also passes the client address in two headers. You do not need that to sign in; if you add it, set trusted_proxies to your proxy network too.
Start it and check:
cd ~/docker_volumes/caddy && docker compose up -d curl -I https://headscale.example.com/health
For tls internal our test returned HTTP/1.1 200 OK with {"status":"pass"} (with curl -k, because the root was not trusted yet). Copy Caddy's root certificate out of its container, then install it on each device:
docker cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
These are standard operating system commands, not from the Headscale or Caddy docs, and not tested with the Tailscale apps:
- Linux (Debian and Ubuntu): copy it to
/usr/local/share/ca-certificates/caddy-root.crt, then runsudo update-ca-certificates. - Windows (PowerShell, as administrator):
certutil -addstore -f "ROOT" caddy-root.crt - Mac (Terminal):
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt
We could not confirm that every Tailscale app, such as the phone apps, accepts a locally trusted root. Beyond a lab, use a public domain.
Tip: Open
https://headscale.example.com/healthin your phone's browser before you install any app. If the browser shows a certificate warning, the app will fail too.
Check it worked. curl -I https://headscale.example.com/health returns a status line over HTTPS, and the browser shows {"status":"pass"} without a warning.
Step 4: Create a user and a sign-in key#
Every device belongs to a Headscale user. Create one, then make a one-use key for the first device.
Linux#
cd ~/docker_volumes/headscale docker compose exec headscale headscale users create alice docker compose exec headscale headscale users list docker compose exec headscale headscale preauthkeys create --user 1
Pass the ID from users list to --user: 0.29.7 expects the ID (a name works only in the unreleased 0.30). The key lasts one hour and works once.
Windows (PowerShell)#
Not for the server. To run these commands from your PC, connect to the server over SSH, then run the Linux commands there:
ssh <your-user>@<your-server-address>
Mac (Terminal)#
Same as Windows, from Terminal:
ssh <your-user>@<your-server-address>
Check it worked. In our run users create printed User created, users list showed ID 1, and the key command printed a line starting with hskey-auth-. Treat that key like a password.
Tip: Add
--expiration 10mto the key command if you will use it at once. A leaked key lets a stranger join until it expires or is used.
Step 5: Connect the first device#
Install the normal Tailscale app, then point it at your server. The address is the server_url from Step 2.
Linux#
curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up --login-server https://headscale.example.com --authkey <YOUR_AUTH_KEY>
Windows (PowerShell)#
winget install --id Tailscale.Tailscale tailscale login --login-server https://headscale.example.com
Open a new PowerShell window after the install so tailscale is found. A browser page opens and shows an Auth ID. Approve it on the server:
docker compose exec headscale headscale auth register --user alice --auth-id <AUTH_ID>
Mac (Terminal)#
Install Tailscale from the Mac App Store or tailscale.com/download, then sign in with the command (the tool lives inside the app):
/Applications/Tailscale.app/Contents/MacOS/Tailscale login --login-server https://headscale.example.com
The docs also describe an app route: Option-click the Tailscale menu icon, open Debug, and choose Add Account under Custom Login Server. Approve the Auth ID on the server with the auth register command above. Phones (docs): on iOS use "Use custom coordination server", on Android "Use an alternate server".
Check it worked. On the server:
docker compose exec headscale headscale nodes list
In our test with a Tailscale 1.104.4 container the list showed this (trimmed to the main columns):
ID | Hostname | Name | User | IP addresses | Connected | Expired 1 | lab-test | lab-test | alice | 100.64.0.1 | online | no | | | | fd7a:115c:a1e0::1 | |
Tip: If a device was already signed in to Tailscale's own service, run
tailscale logoutfirst, then the login command above. A device talks to one control server at a time.
Step 6: Add a second device and test the tunnel#
Repeat Step 5 on another device (new key or new Auth ID). Then test from the first device.
Linux#
tailscale status tailscale ping <other-device-name>
Windows (PowerShell)#
tailscale status tailscale ping <other-device-name>
Mac (Terminal)#
/Applications/Tailscale.app/Contents/MacOS/Tailscale status /Applications/Tailscale.app/Contents/MacOS/Tailscale ping <other-device-name>
Check it worked. tailscale status lists both devices with 100.64.x.x addresses. tailscale ping answers pong from ...; it may first say via DERP (the relay) and then switch to a direct path. Short names work through MagicDNS.
Tip: Out of the box every device can reach every other device. Read the access rules below before you add other people.
Names and access rules#
Access: the docs say that with no policy file "Headscale allows all traffic between nodes". To restrict it, write a policy file, set policy.path in the config and send Headscale a SIGHUP. A policy with {"grants": []} denies everything. Read the policy page first and test each rule with a second device.
Which clients work: the official Tailscale apps for Linux, FreeBSD, OpenBSD, Windows, macOS, iOS, tvOS and Android. The project supports the last 10 client releases (v1.80.0 or newer); update to v1.104.4 or newer, because 0.29.7 also fixes problems inside the client.
What does not work: Funnel, Serve and network flow logs. Our Ollama over Tailscale guide uses tailscale serve and Tailscale-issued HTTPS names; with Headscale, reach that service by its tailnet address or put Caddy in front of it. If you only want a plain WireGuard VPN with a web admin page, wg-easy is a smaller tool.
Security and testing#
A control server decides who joins your network, so protect it like a password manager.
| Control | How |
|---|---|
| HTTPS | Only the proxy faces the internet (443, and 80 for Let's Encrypt). Port 8080 stays on 127.0.0.1 |
| Metrics and gRPC | Do not publish ports 9090 or 50443 |
| Who is admin | Anyone who can run docker exec on the server controls Headscale. The docker group is root-equivalent |
| Sign-in keys | One use, short expiry, never in chat or screenshots |
| Data folder | ~/docker_volumes/headscale/data holds the server key and database; keep it unreadable for others |
| Access rules | Default is allow all; add a policy before you share the tailnet |
| Same machine | Do not run Headscale on a device of its own tailnet (FAQ: unsupported) |
Quick tests after each change (Linux; on Windows or Mac use curl.exe -I or curl -I against the HTTPS address):
cd ~/docker_volumes/headscale docker compose ps docker compose logs --tail=100 headscale curl -I https://headscale.example.com/health docker compose exec headscale headscale nodes list
What leaves the machine: the startup update check contacts GitHub unless you switch it off (Step 2). Headscale also fetches the relay list from https://controlplane.tailscale.com/derpmap/default (the example's default) and gives it to your devices, which may relay through Tailscale's public servers. Tunnel content stays encrypted.
Common mistakes#
These come from the official issue tracker, the docs' own warnings and common AI assistant answers.
| Mistake | Why it happens | Do this instead |
|---|---|---|
Leaving base_domain: example.com while server_url is under it | The example config's default; the error is server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable | Use a tailnet name that is not a parent of the sign-in host, such as tailnet.example.net |
| Starting the container with no config | Issue #3567 shows three fatal errors on a first run; the maintainers point to the docs | Download the example for your tag and mount it at /etc/headscale/config.yaml |
Keeping listen_addr: 127.0.0.1:8080 in a container | Docker's published port cannot reach a loopback-only listener | Set 0.0.0.0:8080 in the config and publish 127.0.0.1:8080:8080 on the host |
| A proxy without WebSocket upgrade | The sign-in protocol upgrades with a POST and a custom Upgrade header | Use the Caddy file above, or add the same upgrade handling in Nginx or Apache from the docs |
| Putting Cloudflare's proxy or tunnel in front | It looks easy; the docs say it does not work (issue #1468) | Use a normal HTTPS reverse proxy on your own server |
Read-only container without /tmp | The docs' file sets read_only: true; deleting a user then failed with disk I/O error (6410) in issue #3463 | Add /tmp to tmpfs, as in Step 3 |
| Skipping versions when upgrading | The docs require one minor at a time; a jump from 0.26.1 to 0.27.0 ended with noise handshake failed: decrypting machine key in issue #2870 | One minor at a time, newest patch, backup first |
| Reusing the default key | It lasts one hour and works once | Make a new key per device |
AI assistants: headscale nodes register --key or namespaces | Old tutorials; we ran the first and got a deprecation notice, and namespaces is an unknown command | Use headscale auth register --user <USER> --auth-id <AUTH_ID> and headscale users |
AI assistants: ip_prefixes in the config, or preauthkeys create --user alice | The key became prefixes.v4 and prefixes.v6; 0.29.7 expects the user ID | Start from the config example for your tag; use the ID from users list |
AI assistants: tailscale serve, tailscale funnel or Tailscale HTTPS names | They are Tailscale-company services; the docs list Funnel and Serve as missing | Put Caddy in front, or reach the service by its tailnet address |
Tip: Before you paste a config an AI wrote, compare every key with
config-example.yamlat your exact tag. Old keys either do nothing or stop the start.
Tip: Change one thing at a time and read
docker compose logs --tail=100 headscaleafter each; it prints the config line that is wrong.
Tip: Write down your tailnet name and domain, and take a backup, before you add family or team members. Every device's full name ends in
base_domain, so changing it later changes all of them.
Troubleshooting#
| Problem | Solution |
|---|---|
| Container exits at once | docker compose logs --tail=50 headscale; fix the config line it names, then docker compose up -d |
Container shows unhealthy or commands take about 30 seconds | The startup update check cannot reach GitHub (issue #3639, 0.29.7). Set disable_check_updates: true and restart |
/health works on the server but not over HTTPS | The proxy cannot reach headscale:8080. Check both are on the proxy network: docker network inspect proxy |
| Certificate error in Caddy | The DNS record must match the name in the Caddyfile, and ports 80 and 443 must reach the server. Read docker logs caddy |
| Device says it cannot reach the coordination server | Test https://headscale.example.com/health in a browser from that device, then check the proxy and the server_url (a similar report is issue #3346) |
tailscale login prints a page but the device never appears | You did not run headscale auth register --user ... --auth-id ... yet |
| Pre-auth key refused | It expired or was used. Make a new one |
| Windows stops when you log out | Open the Tailscale tray menu, Preferences, and turn on "Run unattended" (Windows docs) |
Known bugs (open on 2026-10-31)#
- Issue #3626: a subnet router does not see its own approved routes unless the policy pairs it with them.
- Issue #3598: on Android, subnet routing stopped after a switch from Wi-Fi to mobile data (reported on 0.29.5).
- Issue #3573: the embedded STUN server answers IPv4 clients with an IPv4-mapped IPv6 address; it only matters if you enable the embedded DERP server.
Diagnostics to run first:
docker compose ps
docker compose logs --tail=200 headscale
docker inspect headscale --format '{{.Config.Image}}'
docker compose exec headscale headscale configtest
Updates and backups#
Update. Read the release page first. The docs require one minor version at a time and the latest patch. For a new patch (for example 0.29.8):
cd ~/docker_volumes/headscale # edit the image tag in docker-compose.yml, then compare config.yaml with config-example.yaml at that tag docker compose pull docker compose up -d docker compose logs --tail=100 headscale
For 0.30 or later, go to the last 0.29 patch first, then one minor at a time. The rollback tag is 0.29.6; restore the backup if the database already changed.
Back up. The docs say to stop Headscale, then copy the folder. Create ~/docker_volumes/headscale/backup.sh:
#!/bin/bash set -e cd "$(dirname "$0")" docker compose stop headscale mkdir -p backups sudo tar -czf "backups/headscale-$(date +%F).tar.gz" config data docker-compose.yml sudo chmod 600 backups/headscale-*.tar.gz docker compose start headscale
Run it once with bash ~/docker_volumes/headscale/backup.sh. To run it every night at 03:00 on Linux, add this line to root's crontab with sudo crontab -e (use the full path, because ~ is not your home folder there):
0 3 * * * /bin/bash /home/<your-user>/docker_volumes/headscale/backup.sh
Keep a copy on another machine and treat it as secret: it holds the server key.
Restore test. Unpack into a new empty folder, never over the live one:
mkdir -p /tmp/restore-test && sudo tar -xzf backups/headscale-<date>.tar.gz -C /tmp/restore-test ls /tmp/restore-test/data
You should see db.sqlite and noise_private.key.
Windows (PowerShell)#
Not for the server. Copy the archive off the Linux server from your PC:
scp <your-user>@<your-server-address>:~/docker_volumes/headscale/backups/headscale-<date>.tar.gz .
Mac (Terminal)#
Use the same scp command from Terminal.
Performance and resources#
- Measured by us (one test device, idle): 13.08 MiB of RAM and 0.18% CPU from
docker stats --no-stream; the image is 113 MB on disk. - How many devices: the FAQ says "100s of devices" can work when little changes, and that performance is not a focus. Plan for a home, a family or a small team.
- Database: SQLite is recommended; PostgreSQL is in "maintenance mode".
- Monitor:
docker stats headscaleand the logs. The metrics port 9090 stays on loopback.
FAQ#
Is Headscale free? Yes. It is open source under the BSD 3-Clause licence. You still need a server and a domain.
Is it made by Tailscale, and do I need a public IP and a domain? No, it is a separate community project. For devices outside your home you need a public address, a domain and HTTPS on 443. For a lab at home, tls internal works if each device trusts Caddy's root.
Can Windows or Mac be the server? The docs name Linux and BSD systems, so this guide does not support them as servers. They are fine as clients.
Can I use it with the Tailscale company's service too? A device talks to one control server at a time, so log out of one before you log in to the other. Devices on the two services cannot see each other.
Does it have a web page? No. The CLI inside the container is the admin interface. The docs list a few community web interfaces; this guide does not cover them.
Sources#
- Headscale release v0.29.7, README, LICENSE, CHANGELOG and config-example.yaml - read October 2026
- Headscale docs at v0.29.7: requirements and ports, container, upgrade, getting started, registration, reverse proxy, TLS, policy, clients, features, FAQ, and the connect pages for Windows, Apple and Android - read October 2026
- Headscale image on GitHub Container Registry (tag
0.29.7checked) - October 2026 - Caddy release v2.11.7 and Caddy tls directive - read October 2026
- Tailscale Linux, Windows and macOS download pages - read October 2026
- GitHub issues #3639, #3567, #3463, #2870, #3346, #3626, #3598, #3573, #1468 - read October 2026
- Our guides: Ollama over Tailscale and wg-easy
Tested by DIY Lab Hub on 2026-10-31 (container, Caddy and one test client on Docker Desktop). Checked against the official docs on 2026-10-31.