Headscale is CLI-only by default. Adding nodes, approving devices, creating users, and checking routes all require SSH commands. headscale-ui is a static web frontend that calls the Headscale API and moves these operations into the browser. This post records installation, reverse proxy setup, and login pitfalls.
系列:Tailscale Lab 2 / 4
- 1 What Is Tailscale: A Practical Introduction to P2P Private Networking
- 2 Adding a Web UI to Headscale: headscale-ui Setup Notes 当前
- 3 Self-Hosted DERP Relay: Stable 20ms Domestic Fallback for Headscale
- 4 Joining Devices to Self-Hosted Headscale: From tailscale up to headscale-ui Management
Background: in the prologue, I explained the principles of Tailscale and self-hosted Headscale. Assume you already have Headscale running. Native Headscale is CLI-only: adding nodes, approving devices, creating users, and configuring subnet routes all require SSH and
headscalecommands. Once you have more devices, this becomes annoying. headscale-ui is a static web frontend built by the community. It calls the Headscale API directly and brings these operations into the browser. This post records installation and two reverse-proxy / login pitfalls.
How It Works
headscale-ui has no backend. It is just static HTML and JavaScript. In your browser, it uses the API Key you provide to call Headscale’s HTTP API directly. Deployment is mainly two things:
- Host the static files, either through Docker or an nginx directory.
- Use a reverse proxy to put the static UI and the Headscale API under the same domain, avoiding browser CORS issues.
flowchart LR
U["Browser
headscale-ui"]
P["Reverse proxy
nginx / Caddy"]
UI["Static files
/web"]
API["Headscale API
:8080"]
U -->|"visit /web"| P
P -->|"1. static page"| UI
U -->|"API calls with API Key"| P
P -->|"2. forward API"| API
style P fill:#dbe9ff,stroke:#2563eb,stroke-width:2px
Flow Overview
flowchart TD
A["1. Run headscale-ui
Docker / static files"] --> B["2. Generate API Key
headscale apikeys create"]
B --> C["3. Reverse proxy same domain
/web + API"]
C --> D["4. Open /web in browser
enter URL + API Key"]
D --> E["5. Verify device list"]
style C fill:#ffe9d5,stroke:#b71d18
Step 3 is the easiest place to get stuck. If CORS or HTTPS is mismatched, the UI keeps spinning and cannot reach the API.
Prerequisites
- A running Headscale instance. This post assumes it listens locally on
:8080and is exposed asvpn.e7coding.com. - A reverse proxy with certificates, such as nginx or Caddy. HTTPS is strongly recommended.
Step 1: Run headscale-ui
Choose one of the two methods.
Option A: Docker
docker run -d \
--name headscale-ui \
--restart unless-stopped \
-p 9443:443 \
ghcr.io/gurucomputing/headscale-ui:latestThe container runs a small web server with the static files and listens on 443, mapped to host port 9443. The reverse proxy will point /web to it.
Option B: Static Files + nginx
Download the latest release package from Releases, then extract it into an nginx site directory:
mkdir -p /var/www/headscale-ui
unzip headscale-ui.zip -d /var/www/headscale-uiThen map /web to this directory in nginx.
Step 2: Generate an API Key
headscale-ui authenticates with an API Key. Generate one on the Headscale server:
# Create a long-lived key; adjust expiration as needed
headscale apikeys create --expiration 999d
# Output example:
# xxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyCopy the full key immediately. It is only shown in full once.
# You can list or expire old keys later
headscale apikeys listStep 3: Reverse Proxy UI and API Under the Same Domain
The key is to expose /web for the UI and Headscale API paths under the same domain. Here is an nginx example using Docker option A:
server {
listen 443 ssl;
server_name vpn.e7coding.com;
ssl_certificate /etc/nginx/certs/vpn.e7coding.com.crt;
ssl_certificate_key /etc/nginx/certs/vpn.e7coding.com.key;
# 1. Web UI: forward to headscale-ui container
location /web {
proxy_pass https://127.0.0.1:9443;
proxy_set_header Host $host;
}
# 2. Headscale API / control plane
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}If using static files, replace location /web with alias /var/www/headscale-ui;, then reload nginx:
nginx -t && systemctl reload nginxStep 4: Open the UI
Visit:
https://vpn.e7coding.com/web/In settings, fill in:
- Headscale URL / API URL:
https://vpn.e7coding.com(your public domain, without /web) - API Key: the key from step 2
After saving, you should see the devices list. My final device management page is https://vpn.e7coding.com/web/devices.html.
Step 5: Verify
If you can see devices, approve / delete devices, and create users, it works. Common operations now become clickable:
- Approve new devices after
tailscale upgenerates a register link. - Create / delete users.
- Configure subnet routes and tags.
- Manage and expire API keys.
Pitfalls
- CORS: UI and API must share the same domain. If UI is on
a.comand API onb.com, the browser blocks requests. A reverse proxy under one domain is the most stable approach. - HTTPS is almost mandatory: browsers often block mixed content when an HTTPS page calls an HTTP API.
- API Key is shown once: if you did not save it, expire it and create a new one.
- Do not enter /web as the API URL: settings need Headscale’s root domain, not the UI path. Otherwise you may see 401 or connection failures.
- API Keys expire: after
--expiration, the UI suddenly stops working with auth errors. Create a new key before it expires and replace it in the UI.
With the web UI ready, the next post can approve a self-hosted DERP node in headscale-ui: Self-hosted DERP relay.
