Production and hardening¶
The quick start gets a working server in minutes. This page is what to do before you rely on it: a production checklist, then each hardening step in detail.
Security notice
Headscale Easy is young, security-sensitive networking software, written with extensive AI assistance and not independently audited (see Security and AI usage). Review it and harden your deployment before you expose it to the Internet.
Production checklist¶
- [ ] A dedicated host or VM, kept up to date (unattended security upgrades).
- [ ] A real domain and
HSE_TLS=auto(Let's Encrypt), oroffbehind a proxy you already trust. No plainhttp://URL outside a LAN. - [ ] Host firewall: only 22 (from your IP), 80, 443 and UDP 3478 open.
- [ ] Two-factor authentication required for admins (
MFA_REQUIRED=adminsoreveryone), and a long password for every administrator. - [ ] Self-registration off (
HSE_SIGNUP=off, the default) or restricted to invitation keys, unless you really want anyone to be able to sign up. - [ ]
NETWORK_ISOLATION=trueunless you have written your own ACL policy. - [ ] The console reachable only from where you administer it (LAN, VPN or tailnet), if you can — see below.
- [ ] The image pinned to a version (
HSE_VERSION), and nothing mounting the Docker socket (the container does not need it). - [ ] Nightly backups copied off the server, and a restore tested once.
- [ ] Someone watching releases and updating.
Firewall¶
The container publishes only TCP 80 and 443 and UDP 3478. On the host, allow just
what is needed, for example with ufw:
sudo ufw default deny incoming
sudo ufw allow from <your-admin-ip> to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp # STUN for the embedded DERP relay
sudo ufw enable
Note
Docker publishes ports by editing iptables itself and bypasses ufw for
container ports. That is fine here — the only published ports are the ones
above — but do not publish extra ports (Headscale's 9090 metrics, 50443
gRPC, the internal 8080) on a public host: they are not meant to leave the
container.
HTTPS¶
HSE_TLS=auto(recommended): Caddy gets and renews the certificate and redirects HTTP to HTTPS. Needs ports 80 and 443 reachable and a DNS record pointing at the host.offbehind a proxy: your own proxy terminates TLS and forwards to the container on port 80. Then the connection between your proxy and this host is plain HTTP: keep it on a private network or on the same machine, and make sure the proxy passes WebSockets and does not buffer (the examples do both). SetHSE_TRUSTED_PROXIESto the proxy's address only, never to a whole network.internal: only for tests or closed networks; every client must trust Caddy's root certificate.- Plain
http://: LAN only. Cookies are then notSecureand passwords travel in clear text. The first-run wizard is served this way: run it from a trusted network, or start headless withHSE_PUBLIC_URL.
Sign-in¶
- Local accounts (default): passwords are stored as salted hashes, failed sign-ins are rate limited, and admins can be forced to use two-factor. Set the administrator's password with a long passphrase, and give admin rights only to the people who need them (a Network admin or Auditor role is often enough).
- Sign-up:
HSE_SIGNUP=offhides the link and the endpoint answers 404. Withinvitea key is needed (shown once, stored hashed, single or multi use, with an expiry);openlets anyone create a member account, never an admin. - An external OIDC provider: make sure it verifies e-mail addresses if you use
PORTAL_ADMIN_EMAILS(an e-mail it marks as not verified never grants admin), and prefer groups (PORTAL_ADMIN_GROUPS) if it can send agroupsclaim. Restrict who may sign in withHSE_OIDC_ALLOWED_*: empty means everyone the provider lets in, which is wrong for Google. Enforce two-factor there. - API key sign-in is an emergency way in for administrators. It is on only while no OIDC provider is configured; with OIDC, prefer to keep it off.
- Sessions last 8 hours and are kept server-side (
/data/console/sessions.db,600), so they can be revoked at once: Settings → Sessions lists them (yours; admins and auditors see everybody's) with Log out per session, Sign out everywhere and, for admins, Sign out everyone else. Deleting a user, or a change of someone's role at their next sign-in, revokes their older sessions. ChangingSESSION_SECRETinvalidates every cookie, if you ever need to. - Sign-in attempts are rate limited per client IP: after 10 failed sign-ins within
600 seconds the console answers
429 Too Many Requestswith aRetry-Afterheader and logs anauth.rate_limitedevent. Behind a proxy, setHSE_TRUSTED_PROXIESor every person is counted as the proxy's address.
Restrict the console¶
Tailscale clients only need the control plane (the domain root) and, with an external provider, the sign-in pages. The admin console does not need to be public.
The container's own Caddy is generated from your settings, so put the restriction in a proxy or firewall in front of it (see A proxy in front), or reach the console through the tailnet only. For nginx:
location /console {
allow 192.168.0.0/16; # your LAN
allow 100.64.0.0/10; # your tailnet
deny all;
proxy_pass http://<headscale-easy-host>:80;
# ...the rest of the location block from the example
}
In Nginx Proxy Manager use an Access List on a custom location /console; in
Traefik an ipAllowList middleware on a router for PathPrefix(/console).
Do not restrict /oidc, /authentik or /console/register
New devices sign in through them from a browser before they are on
the tailnet. Restricting them breaks enrolment. (/console/register/<id> is the
page a new device opens to be approved.)
Headscale's own REST API (/api/v1/…) is served on the same domain. The console
talks to Headscale inside the container, so if nothing outside the server calls
that API, you can block /api/ in your front proxy as well.
No Docker socket¶
Access to the Docker socket is equivalent to root on the host, so Headscale
Easy has none: not the console, not the compose file.
Validating the config and restarting Headscale are done by a small supervisor
inside the container, which answers a fixed set of requests from the console on
a Unix socket: validate the config (headscale configtest), restart Headscale, and
report the health of the three processes. The console cannot ask it to run anything
else.
The container runs as an unprivileged user (uid 1000) with no added Linux
capability and no-new-privileges, and the compose file drops all of them. Keep it
that way, and do not mount the Docker socket into it. The three processes
(Headscale, Caddy and the console) share the container, so a flaw in one reaches the
others: that is why the data is private to uid 1000 (700 / 600) and the container
should sit behind your firewall, not next to workloads you do not trust.
Secrets¶
/data/config/settings.jsonholds your settings, including the OIDC client secret and the SMTP password if you set them;/data/config/session-secretsigns the sessions;/data/console/api-keyis the console's renewed Headscale API key;/data/console/accounts.dbhas the password hashes and two-factor secrets. All are created600inside a700directory; keep them that way. The.envnext to the compose file (600) holds what you passed as variables: never commit it.- Rotate after a suspected leak: a new
SESSION_SECRET(or delete/data/config/session-secretand restart), a new Headscale API key (delete/data/console/api-keyand restart), and a new OIDC client secret in your provider. - Do not paste
.env,settings.jsonor backups into issues.
Backups¶
A backup holds password hashes, two-factor secrets, the OIDC client secret and Headscale's private keys: whoever has one can impersonate your server.
- Copy the nightly backups off the server: mount a NAS folder over
/data/backups, or use thebackup-remotesidecar (S3, B2, SFTP, rsync). See Operations → Remote backups. - Encrypt copies that leave your network, for example:
gpg --symmetric --cipher-algo AES256 headscale-easy-*.tar.gz. - Test a restore once, ideally on another machine (see All-in-one → Backing up and restoring).
Updates¶
Pull the new image and recreate the container; the data is in the volume. Watch the
project's releases and
those of Headscale for security
fixes. To control when upgrades happen, pin the version (HSE_VERSION=2.0.0, or an
exact image tag). Take a backup first (docker exec headscale-easy hse backup).
Logs¶
- Caddy access log (
/data/caddy/logs/): every request with client IPs. - Activity log (Logs page,
/data/console/audit.db,600): who changed what, sign-ins, failed sign-ins, rate-limit blocks and revoked sessions with IPs, kept 90 days. Secrets are never written to it: keys are reduced to a prefix and invitation or reset links are not stored. - Container logs (
docker logs headscale-easy): no secrets by design, but they do contain user names, e-mails and IPs. The one-time setup token is printed there until setup finishes.
These are personal data in many jurisdictions: keep them only as long as you need, and include them in your privacy notice if others use your server.
Network exposure summary¶
| Exposed | Why | Can you close it? |
|---|---|---|
TCP 443 / |
Control plane for Tailscale clients | No |
TCP 443 /oidc, /authentik |
Device and user sign-in with an external provider | No, if you use one |
TCP 443 /console/register |
A new device opens it to be approved | No |
TCP 443 /console |
Web console | Yes — restrict to LAN / tailnet in a proxy in front |
TCP 443 /api/v1 |
Headscale REST API | Yes, if nothing remote uses it |
| TCP 80 | Let's Encrypt and redirect to HTTPS | With a proxy in front, your proxy handles it |
| UDP 3478 | STUN for the embedded DERP relay | Only if you use Tailscale's DERP servers instead |
See Security for the full security model and known limitations.