Post

Self-host Headscale with a protected web UI

Deploy Headscale behind Traefik and protect the optional web UI with Authentik while keeping the control API reachable.

Self-host Headscale with a protected web UI

Headscale is an open-source control server for Tailscale-compatible WireGuard networks. It is a great homelab tool because it lets you manage private devices without depending entirely on a third-party coordination server.

In this setup:

  • headscale.<your-domain> exposes the Headscale control endpoint.
  • headscale-ui.<your-domain> exposes a browser UI.
  • Traefik handles TLS.
  • Authentik protects only the UI.
  • The Headscale API remains available for clients.

Folder layout

1
2
3
4
5
6
/home/ubuntu/headscale/
├── docker-compose.yml
├── .env
├── config/
│   └── config.yaml
└── lib/

This keeps the service self-contained and easy to back up.


Compose file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale
      - ./lib:/var/lib/headscale
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.headscale-secure.entrypoints=https"
      - "traefik.http.routers.headscale-secure.rule=Host(`headscale.<your-domain>`)"
      - "traefik.http.routers.headscale-secure.tls=true"
      - "traefik.http.routers.headscale-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.headscale-secure.service=headscale"
      - "traefik.http.services.headscale.loadbalancer.server.port=8080"

  headscale-ui:
    image: ghcr.io/gurucomputing/headscale-ui:latest
    container_name: headscale-ui
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.headscale-ui-secure.entrypoints=https"
      - "traefik.http.routers.headscale-ui-secure.rule=Host(`headscale-ui.<your-domain>`)"
      - "traefik.http.routers.headscale-ui-secure.middlewares=authentik@docker"
      - "traefik.http.routers.headscale-ui-secure.tls=true"
      - "traefik.http.routers.headscale-ui-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.headscale-ui-secure.service=headscale-ui"
      - "traefik.http.services.headscale-ui.loadbalancer.server.port=8080"

networks:
  proxy:
    external: true

The important detail is the UI port: many people assume it is 80, but this UI listens on 8080 internally.


Minimal Headscale config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server_url: https://headscale.<your-domain>
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
private_key_path: /var/lib/headscale/private.key
noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
unix_socket: /var/run/headscale/headscale.sock
log:
  level: info
policy:
  mode: database

Your real config can include DNS, DERP, ACLs, and OIDC later.


Create users

1
2
3
cd /home/ubuntu/headscale
docker compose exec headscale headscale users create nox
docker compose exec headscale headscale users create laptop

Register a node

On the client:

1
tailscale up --login-server https://headscale.<your-domain>

On the server:

1
2
3
docker compose exec headscale headscale nodes register \
  --user nox \
  --key <machine-key-from-client>

Rename a node:

1
docker compose exec headscale headscale nodes rename <node-id> <new-name>

Protect only the UI

The Headscale control server is used by Tailscale clients. Do not put an interactive browser SSO flow in front of it unless you know exactly how your clients authenticate.

Protect the UI instead:

1
- "traefik.http.routers.headscale-ui-secure.middlewares=authentik@docker"

And add an Authentik outpost route:

1
- "traefik.http.routers.authentik-headscale-ui-outpost.rule=Host(`headscale-ui.<your-domain>`) && PathPrefix(`/outpost.goauthentik.io/`)"

Health checks

1
curl https://headscale.<your-domain>/health

Expected:

1
{"status":"pass"}

Check UI auth:

1
curl -I https://headscale-ui.<your-domain>/web

Expected:

1
2
HTTP/2 302
location: https://auth.<your-domain>/application/o/authorize/...

Lessons learned

  • Keep Headscale and Headscale UI separate in your mental model.
  • The control endpoint is for clients; the UI is for humans.
  • Authentik is perfect for the UI.
  • Traefik labels are enough; no extra public ports are needed.

These posts connect to this topic and help build the bigger homelab picture:

This post is licensed under CC BY 4.0 by the author.