A WebAssembly-powered client for connecting to your Headscale nodes via SSH, VNC, or RDP — directly from the comfort of your browser. Includes an optional self-service integration for seamless device onboarding and management
- SSH Console - Secure terminal access to your nodes
- VNC Viewer - Remote desktop viewing in the browser
- RDP Client - Secure access to Windows nodes, inspired by Cloudflare, without requiring a additional gateway
- Self-Service - Optional tsnet-based server allowing users to manage their own devices via an opt-in approach. It connects directly to the Headscale gRPC UNIX socket and requires minimal setup and maintenance
- Stateless - Integrates into existing infrastructure without the need for a extra database or public API's
Headscale uses plain WebSockets for both the control API and DERP relays, something every browser already supports. That means with the help of some WebAssembly the console can piggy-back any protocol (SSH, VNC, RDP, you name it) through a DERP relay, completely bypassing the browser’s usual restrictions.
Headscale does not allow cross origin requests by default.
- Either the console must be served from the same domain as Headscale,
- or Headscale must return the correct
Access-Control-Allow-Origin
headers.
typically handled via a reverse proxy - see docker compose example
A minimal Docker image is available, featuring a Go web server to serve the static files or to run the self-service API.
docker run -it ghcr.io/rickli-cloud/headscale-console:unstable serve --help
docker run -it ghcr.io/rickli-cloud/headscale-console:unstable selfservice --help
latest
: Latest stable releasex.x.x
: Specific release versionsx.x.x-pre
: Pre-release versions (potentially unstable)unstable
: Built on every push to the main branch
A full deployment of traefik, headscale, headscale-console & headscale-selfservice can be found in docker-compose.yaml
.
-
Configure headscale in
config.yaml
-
Configure the environment in
.env
:# Required HEADSCALE_SERVER_HOSTNAME=headscale.example.com HEADSCALE_VERSION=0.26.1 # Optional HEADSCALE_CONSOLE_VERSION=unstable TRAEFIK_LISTEN_ADDR=0.0.0.0 TRAEFIK_VERSION=latest
-
Create
config.json
{ "selfserviceHostname": "self-service" }
See configuration for more
-
Start it all up:
docker compose up -d
Important
When deploying for production it is recommended to use TLS between traefik and headscale.
Each release includes a downloadable ZIP archive with all required assets for deployment on static web servers (e.g., Nginx, Apache).
All assets are loaded relative to the initial URL, so it does not matter which path you serve the app from.
Configuration is completely optional
On startup the console tries to load ./config.json
. For the docker image you can mount /config.json
(or somewhere else defined with the configfile flag).
Key | Type | Default | Description |
---|---|---|---|
logLevel | string | "INFO | "OFF" | "ERROR" | "WARN" | "INFO" | "DEBUG" | "TRACE"; |
controlUrl | string | Base URL | The Headscale control url. E.g. https://headscale.example.com |
selfserviceHostname | string | Used to identify the self-service node. If undefined all self-service features will be hidden | |
tags | string[] | Only apply when using a authkey |
Manual instructions are available in wasm/
. CI workflows also publish prebuilt WASM artifacts.
Requires WASM builds
Install dependencies:
deno install
Build the frontend:
deno task build
This could also be done inside a Docker container:
docker run -it --rm -v .:/work:rw --workdir /work --entrypoint /bin/sh denoland/deno:latest
Requires frontend build
docker build . -t headscale-console:custom
Requires frontend build
If you do not plan on running the console inside of docker you need to build the executable manually:
go build main.go
This builds a native binary for your current OS and architecture. For other platforms, build natively or set appropriate cross-compilation flags.
Authentication via the IdP can occur in a new tab or on a separate device, but the original tab must remain open at all times to receive the authorization callback.
sequenceDiagram
Participant user as User
Participant js as JavaScript
Participant go as Go WASM
Participant headscale as Headscale
Participant idp as Identity Provider
user->>js: Opens tab
js->>go: Start client
go->>js: Notifies: NeedsLogin
js->>go: Calls login()
go->>headscale: Request authorization URL
headscale->>go: Provides unique URL
go->>js: Notifies: browseToURL
js->>user: Display URL (Link/QR)
rect rgba(255,255,255,0.1)
user->>idp: Opens link / Scans QR
Note over user,idp: New tab or device
idp->>headscale: user authenticates and authorizes device
end
headscale->>go: Notifies: Authorized
go->>js: Notifies: Running
js->>user: Render UI
Go handles the full protocol stack. JavaScript handles rendering.
sequenceDiagram
Participant derp as DERP
Participant go as Go WASM
Participant js as JavaScript
Participant dom as DOM
derp ->> go: WebSocket
go ->> js: Text
js ->> dom: Render
js -->> go: Input
go -->> derp: WebSocket
Go handles the TCP layer. JavaScript (NoVNC) manages the VNC protocol.
sequenceDiagram
Participant derp as DERP
Participant go as Go WASM
Participant js as JavaScript
Participant dom as DOM
derp ->> go: WebSocket
go ->> js: TCP data
js ->> dom: Render
js -->> go: TCP data
go -->> derp: WebSocket
Go handles the TCP layer. JavaScript passes packets to the Rust-based WASM module, which handles TLS, RDP, and rendering.
sequenceDiagram
Participant derp as DERP
Participant go as Go WASM
Participant js as JavaScript
Participant rust as Rust WASM
Participant dom as DOM
derp ->> go: WebSocket
go ->> js: TCP data
js ->> rust: TCP data
rust ->> dom: Render
js -->> rust: UI events
rust -->> js: TCP data
js -->> go: TCP data
go -->> derp: WebSocket
Reaches out via the derp relay. Traffic is not encrypted with TLS (already protected by the underlying WireGuard tunnel).
sequenceDiagram
Participant js as JavaScript
Participant go as Go WASM
Participant derp as DERP
Participant selfservice as Self-Service
Participant headscale as Headscale
js ->> go: HTTP request
go ->> derp: WebSocket
derp ->> selfservice: WebSocket
selfservice ->> headscale : GRPC
headscale -->> selfservice : GRPC
selfservice -->> derp: WebSocket
derp -->> go: WebSocket
go -->> js: HTTP response
The TCP connection (handled by Golang) is abstracted into a
IpnRawTcpChannel
on JS side. It implements theRTCDataChannel
interface to allow use with NoVNC & IronRDP but has nothing to do with WebRTC.
Thoughtful feedback is always appreciated, whether it's related to design decisions, usability, or ideas for improvement. Feel free to open an issue to start a conversation. While not every suggestion can be implemented, each one is reviewed and considered with care.
Contributions are welcome! However, to avoid wasted effort, please open an issue first to discuss any significant changes before submitting a pull request. Bug fixes, improvements, and well-scoped features are especially appreciated — just make sure they align with the project's direction.
MIT License - Copyright (c) 2025 rickli-cloud
Made with ❤️ for secure and hassle-free remote access.