A self-hosted web panel for headscale — the open-source implementation of the Tailscale coordination server.
headscale runs your tailnet; it talks to you in HuJSON policy. NodeRoost restates that in the terms you already think in — servers, devices, grants, routes — and assembles the policy for you.
![]() Access — who reaches which server, on which port |
![]() Routing — who goes where, through which node |
Screens are rendered from the panel's own stylesheet with example data; the addresses are documentation ranges (RFC 5737), not a live tailnet.
Servers and devices. Nodes split into servers (things you connect to) and personal devices. Devices are isolated from one another and can never be a rule's target — enforced in the policy engine, not in the UI, because a rule can also arrive through the API.
Grants, not an ACL file. A rule is who → where → on which port. Grant it by clicking, to several nodes at once, or from inside a node's own page. The HuJSON is generated, pushed, and rolled back if headscale refuses it.
Roles. A role is a group of servers (a headscale tag). Grant access to the role; adding a server to it later needs no rule changes.
Routing. Directions — these nodes reach that address through this node. Give a hostname and the panel resolves it, keeps the route in sync when the site moves, and refuses addresses that would hijack traffic inside the mesh.
Internet egress through gateways. Mark a server as an exit gateway and pick which devices may use it. In the Tailscale tray each user sees only the exits you allowed — the choice is theirs, the set is yours. A node's whole outbound traffic can also be forced through a gateway while the node stays reachable on its public IP.
An agent on the node. headscale can approve routes but has no channel to tell a
node what to advertise, so NodeRoost ships a small POSIX-sh agent: a systemd timer
pulls the desired state, applies it with tailscale set, and reports the hash of
what it actually applied.
Backups, monitoring, alerts. Consistent snapshots of the headscale database and panel settings with a self-test and a rehearsed restore; uptime history; Telegram or webhook alerts for a downed server or an expiring key; and an independent host watchdog for when the panel itself goes quiet.
The panel runs a VPN network, so the guarantees live in code and are covered by tests:
- devices never reach each other, and a device is never a rule's destination — not by picking it from a list, not by typing its tailnet address by hand;
- a node cannot promote itself: its class and its tags follow only what the administrator approved, never what the node announces about itself;
- tags are owned by an empty group, so no node can apply one to itself;
- sign-in is JWT + optional TOTP with attempt limits and an audit log; the panel refuses to start with a weak secret or the default password;
- releases build from a hash-verified dependency lock, with base images pinned by digest.
Deployment notes that matter — network isolation, what is exposed publicly — are in SECURITY.md. Read it before putting this on the internet.
- Docker with Compose
- a reverse proxy terminating TLS (the compose overlay ships caddy-docker-proxy labels; give NodeRoost a network of its own — see SECURITY.md)
- two DNS names: one for the panel, one public one for the control server
git clone https://github.com/mihsergeev/NodeRoost.git
cd NodeRoost
cp .env.example .envFill in .env — at minimum a random NODEROOST_JWT_SECRET (openssl rand -hex 32),
a strong NODEROOST_ADMIN_PASSWORD, NODEROOST_DB_PASSWORD, your two domains and
the addresses allowed to reach the panel. Then put the headscale config in place:
mkdir -p data/headscale/config
cp deploy/headscale/config.example.yaml data/headscale/config/config.yaml
$EDITOR data/headscale/config/config.yaml # server_url, base_domain, prefixes
docker compose up -dThe backend needs a headscale API key, which can only be created once headscale is running:
docker compose exec headscale headscale apikeys create --expiration 999d
# put it in .env as NODEROOST_HEADSCALE_API_KEY, then:
docker compose up -d backendSign in with the admin credentials from .env and add your first node — the panel
hands you a one-time key and a ready-made command for Linux, Windows, macOS or
Android.
┌───────── public ──────────┐ ┌──── allow-listed ────┐
nodes ───► │ hs.example.com │ │ panel.example.com │
│ headscale, node endpoints │ │ SPA + panel API │
│ only (/api/v1 → 404) │ └──────────┬───────────┘
└──────────┬────────────────┘ │
│ internal docker network │
└─────────► headscale API ◄────────┘
The panel reaches headscale's management API only over the internal network; on the
public vhost /api/v1 and /swagger return 404, leaving the node-facing endpoints.
Panel state lives in Postgres, headscale keeps its own SQLite.
cd backend && python -m venv .venv && .venv/bin/pip install -e . && pytest
cd frontend && npm install && npm run devBSD-3-Clause — see LICENSE.



