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.

Headscale Private access Self-hosted control plane
Illustrated guide cover for Set Up Headscale for Private Tailnet Access to Self-Hosted Services
Headscale • Private access • Self-hosted control plane
What you learn

How to run Headscale, terminate TLS at a reverse proxy, create users, and register a client without opening your real admin services publicly.

Best for

Operators who already understand why private overlay access is useful and specifically want control over the coordination server.

Risk to watch

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.

Warning: the Headscale project itself treats Docker deployment and reverse-proxy examples as community documentation. Keep the setup conservative and test every assumption instead of assuming every blog recipe is correct.

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.com
  • listen_addr: 0.0.0.0:8080
  • tls_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.