Reverse proxy
The recommended setup serves both from one domain: Northplane under /admin,
Headscale on everything else. Both then share one certificate, and because the
browser sees a single origin, no CORS headers are needed.
Whatever the proxy:
- Route
/adminto Northplane (port 3000) and everything else to Headscale (port 8080). - Forward WebSocket upgrades to Headscale. Tailscale clients and Browser SSH both rely on them.
- Set
server.base_urlin Northplane's config to the public URL, such ashttps://headscale.example.com. SSO needs it to build its callback URL.
Leave server.cookie_secure at its default of true when the proxy serves
HTTPS. Set it to false only when Northplane is reached over plain HTTP, or
sign-in won't stick.
Caddy
Caddy forwards WebSocket upgrades without extra configuration:
headscale.example.com {
# Optional: send visitors at / to Northplane
redir / /admin/
handle /admin* {
reverse_proxy localhost:3000
}
handle {
reverse_proxy localhost:8080
}
}nginx
nginx needs the map block to forward WebSocket upgrades:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name headscale.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location = /admin {
return 301 /admin/;
}
location /admin/ {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Traefik
With Traefik's Docker provider, the routing goes in container labels. Add a
certificate setup to the traefik service. Traefik forwards WebSocket upgrades
without extra configuration.
services: headplane: image: ghcr.io/tale/headplane:latest container_name: headplane restart: unless-stopped volumes: - "/path/to/headplane/config.yaml:/etc/headplane/config.yaml" - "/path/to/data/storage:/var/lib/headplane" labels: - "traefik.enable=true" - "traefik.http.routers.headplane.rule=Host(`headscale.example.com`) && PathPrefix(`/admin`)" - "traefik.http.routers.headplane.entrypoints=websecure" - "traefik.http.routers.headplane.tls=true" - "traefik.http.services.headplane.loadbalancer.server.port=3000" headscale: image: headscale/headscale:0.29.5 container_name: headscale restart: unless-stopped command: serve volumes: - "/path/to/headscale/config.yaml:/etc/headscale/config.yaml" - "/path/to/headscale/data/storage:/var/lib/headscale" labels: - "traefik.enable=true" - "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)" - "traefik.http.routers.headscale.entrypoints=websecure" - "traefik.http.routers.headscale.tls=true" - "traefik.http.services.headscale.loadbalancer.server.port=8080" # Optional: send visitors at / to Northplane - "traefik.http.routers.root.rule=Host(`headscale.example.com`) && Path(`/`)" - "traefik.http.routers.root.entrypoints=websecure" - "traefik.http.routers.root.tls=true" - "traefik.http.routers.root.middlewares=to-admin" - "traefik.http.middlewares.to-admin.redirectregex.regex=^(https?://[^/]+)/$$" - "traefik.http.middlewares.to-admin.redirectregex.replacement=$${1}/admin/" traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped command: - "--providers.docker.exposedbydefault=false" - "--entrypoints.websecure.address=:443" ports: - "443:443" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro"Separate domains
Northplane and Headscale can also live on different domains, such as
headplane.example.com and headscale.example.com. Everything works the same
except Browser SSH, which connects from the browser straight
to Headscale. Its WebSocket connections aren't subject to CORS, but it first
fetches Headscale's public key with a plain cross-origin request, so
Headscale's responses need an Access-Control-Allow-Origin header with
Northplane's origin. In Caddy:
headscale.example.com {
reverse_proxy localhost:8080
header Access-Control-Allow-Origin "https://headplane.example.com"
}In nginx, add add_header Access-Control-Allow-Origin "https://headplane.example.com" always;
to Headscale's location.
A different path
To serve Northplane somewhere other than /admin, such as /tools/headplane,
set server.base_path and restart Northplane:
server:
base_path: "/tools/headplane"The path starts with a slash, doesn't end with one, and can't be /. Route it
to Northplane in the examples above in place of /admin, without stripping it.
Keep the path out of server.base_url. With SSO, register the
callback under the new path. Changing the path signs everyone out, since
sessions are tied to it.
Serving HTTPS without a proxy
Northplane can terminate TLS itself, which suits a single machine without a proxy. See TLS & certificates.