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 /admin to 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_url in Northplane's config to the public URL, such as https://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.

On this page