Set Up Headscale for Private Tailnet Access to Self-Hosted Services
Use Headscale as your own tailnet control plane so dashboards and admin ports stay private, while staying honest about the extra moving parts that come with self-hosting the coordination layer.
How to run Headscale, terminate TLS at a reverse proxy, create users, and register a client without opening your real admin services publicly.
Operators who already understand why private overlay access is useful and specifically want control over the coordination server.
Headscale is not "Tailscale but free in five minutes." DNS, TLS, client registration, and relay behavior all add coordination cost.
Before you begin
- A VPS with a public domain name such as
headscale.example.com. - Docker Compose installed.
- A clear reason to self-host the control plane instead of using hosted Tailscale.
- A private admin plan for the services you eventually want to reach over the tailnet.
Headscale's current docs say the configuration should set a real server_url, and the getting-started flow expects the health endpoint
at https://headscale.example.com/health to be reachable from the internet before you enroll clients. The reverse-proxy docs also note
that TLS can be terminated outside Headscale by leaving tls_cert_path and tls_key_path empty.
Step 1: Prepare the Headscale workspace
mkdir -p ~/apps/headscale/{config,lib}
cd ~/apps/headscale
Download the example configuration that matches the release you plan to run:
curl -fsSL -o config/config.yaml \
https://raw.githubusercontent.com/juanfont/headscale/v0.29.1/config-example.yaml
The configuration reference explicitly says to use the same Git tag as the version you deploy, because the main branch may contain
unreleased changes. Do not casually mix those.
Step 2: Create a minimal configuration
Edit config/config.yaml so the important values are intentional:
server_url: https://headscale.example.comlisten_addr: 0.0.0.0:8080tls_cert_path: ""tls_key_path: ""- SQLite storage under
/var/lib/headscale
Validate the configuration before you run the service:
docker run --rm \
-v "$(pwd)/config:/etc/headscale:ro" \
docker.io/headscale/headscale:0.29.1 \
headscale configtest
Headscale's reference docs expect the config file to be real before startup. A blind copy that still contains placeholder domains is just delayed failure.
Step 3: Run Headscale behind HTTPS
Save this as docker-compose.yml:
services:
headscale:
image: docker.io/headscale/headscale:0.29.1
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./config:/etc/headscale:ro
- ./lib:/var/lib/headscale
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:9090:9090
networks:
- headscale
caddy:
image: caddy:2
container_name: headscale-caddy
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- headscale
networks:
headscale:
volumes:
caddy_data:
caddy_config:
And this as Caddyfile:
headscale.example.com {
reverse_proxy headscale:8080
}
Start the stack and verify the health endpoint:
docker compose up -d
curl https://headscale.example.com/health
Headscale's reverse-proxy notes warn that clients use WebSockets and the control protocol has a couple of quirks. If your chosen proxy does not pass those cleanly, enrollments can fail even when the homepage looks fine.
Step 4: Create a user and register a client
Create a user on the Headscale side first:
docker exec -it headscale headscale users create admin
docker exec -it headscale headscale users list
For the safest first enrollment, start on the client and let Headscale print the auth request:
tailscale up --login-server https://headscale.example.com
Then approve that auth request on the server:
docker exec -it headscale \
headscale auth register --user admin --auth-id YOUR_AUTH_ID
For non-interactive enrollment, generate a preauth key with the numeric user ID instead of the username:
docker exec -it headscale headscale users list
docker exec -it headscale \
headscale preauthkeys create --user YOUR_NUMERIC_USER_ID
Then on the client machine:
tailscale up \
--login-server https://headscale.example.com \
--authkey YOUR_PREAUTH_KEY
Headscale's current CLI expects preauthkeys create --user to receive the user's numeric ID, while
auth register --user uses the username. Use whichever registration method fits your environment best, but do not skip verification just
because a command exited cleanly.
Step 5: Use the tailnet for private service access
Once the first client is enrolled, use the overlay network to reach private web UIs and SSH endpoints instead of public-binding those services. That is the whole operational payoff for doing the extra Headscale work.
Good day-two checks:
docker exec -it headscale headscale nodes list
docker exec -it headscale headscale users list
docker compose logs headscale --tail 100
If some client pairs need a relay path, remember that Headscale includes embedded DERP support, but relay behavior adds another layer to test. Do not promise perfect peer-to-peer connectivity everywhere until you have proven it with your own devices.
Rollback and update notes
Back up both config/ and lib/ before upgrades. The SQLite database and the control-plane config matter together; one
without the other makes recovery harder.
docker compose pull
docker compose up -d
If an upgrade breaks enrollments, stop and restore the last known-good state instead of improvising deep config changes on a live control plane.
Troubleshooting
/health fails over HTTPS.
Check DNS, proxy logs, and whether Headscale is really listening on the port your proxy expects.
Clients cannot enroll even though the site responds.
Suspect reverse-proxy WebSocket handling, stale server_url, or a bad registration method before you blame the client.
You are not sure Headscale is worth it.
That is a valid conclusion. If hosted Tailscale already solves the problem with less coordination burden, use the simpler tool.
Private access works for some devices but not all.
Test NAT behavior and relay needs honestly. Overlay networking failures are often environmental, not just a missing checkbox.