Contributing to Headscale Easy¶
Thanks for helping! Headscale Easy is maintained by Rafa Madolell and every bug report, translation and pull request makes it better.
Ways to help¶
- 🐛 Report a bug
- 💡 Suggest a feature
- 🌍 Translate the console
- 📝 Improve the documentation
- 🔧 Send a pull request
- ⭐ Star the repository and ☕ buy me a coffee on Ko-fi
Project layout¶
compose.yaml The simple install: one service, the pinned image
.env.example Optional answers for the first start
advanced/ Advanced configurations: compose overlays (PostgreSQL, a proxy in front, remote backups, sign-in providers)
aio/ The all-in-one image: Dockerfile, supervisor, config renderer, wizard, backups, `hse`
supervisor.py Starts and restarts Headscale, Caddy and the console; the console's socket protocol
render.py Renders Headscale's config.yaml, the Caddyfile and the DERP map from the settings
wizard.py The first-run setup wizard
backup.py, restore.py, cron.py Built-in backups and their schedule
hse Control CLI: health, reload, backup, backups, restore
templates/ Files the renderer fills in (Headscale config, Caddyfile, PostgreSQL read-only role)
web/ The web console (runs inside the image)
app.py HTTP server, routing, sessions, OIDC
headscale.py Headscale REST API client, DNS config, supervisor client
local_accounts.py Local accounts: passwords, TOTP, invitations, reset links, sign-up keys
pages.py Machines, device, DNS, keys, settings pages
admin_pages.py Users, access controls (raw HuJSON tab), sign-in page
acl_pages.py Access controls: Rules, Groups & tags, Test access tabs
policy.py HuJSON parsing/splicing and the access simulator
ui.py Layout, sidebar, icons, shared helpers
i18n.py, locales/ Translations
static/ CSS, JS, font, favicon
backup/ The `backup-remote` sidecar image (S3, B2, SFTP, rsync)
scripts/ aio-smoke.sh, compose-smoke.sh, advanced-smoke.sh, validate.sh, check_i18n.py
tests/ Unit tests (Python standard library only)
docs/, mkdocs.yml Documentation site (GitHub Pages), screenshots
Principles¶
- Simple to run. One container, one command; restarting the container is safe. Never lose a user's data: keep it in the volume, and tell people in the changelog when something needs their attention.
- No dependencies in the console. Python standard library only, plain HTML/CSS/JS, no build step. It keeps the image tiny and the attack surface small.
- Feels like Tailscale. When adding a screen, look at how Tailscale's admin console does it and follow the same wording and layout where Headscale supports the feature.
- Secure by default. Least privilege (unprivileged user, no capabilities, no
Docker socket), CSRF tokens on every form, escape all output (
ui.esc), members only ever touch their own devices. - Small. The image stays under 250 MB and idles under 100 MB of RAM; CI fails
above 250 MB on every run and above 100 MB on a release tag (
scripts/aio-smoke.sh;HSE_SMOKE_PERF=1runs the RAM measurements locally).
Development¶
You need Docker. The loop for the all-in-one image:
docker build -f aio/Dockerfile -t hse-aio:dev .
./scripts/aio-smoke.sh hse-aio:dev # starts it headless, checks health, a backup, the limits
docker run --rm -p 8080:80 -e HSE_PUBLIC_URL=http://localhost:8080 -e HSE_TLS=off \
-e HSE_ADMIN_EMAIL=me@example.com -e HSE_ADMIN_PASSWORD='a long password' hse-aio:dev
Then open http://localhost:8080/console. To work on the console without a rebuild
each time, mount the sources over the image:
-v "$PWD/web:/app/web:ro" and restart the container. To see the first-run wizard,
leave out HSE_PUBLIC_URL and read the token from docker logs.
Before opening a pull request:
make lint # shellcheck, Python syntax, translation coverage
make test # unit tests
make validate # project structure, compose files, the environment-variable reference
scripts/compose-smoke.sh runs compose.yaml for real (healthy, hardened, a backup,
down && up -d keeps the data) and then the remote backup add-on.
Documentation¶
The docs live in docs/ and are published to
GitHub Pages with
MkDocs Material on every push to
main. English pages are page.md, Spanish ones page.es.md (a missing
translation falls back to English). Keep the { #anchor } ids of translated
headings equal to the English ones: the console links to them.
Preview locally:
Translations¶
UI strings are written in English in the code, wrapped in _() (or
ngettext() for plurals). Each language is a JSON file in web/locales/
mapping the English text to its translation:
{
"Add device": "Añadir dispositivo",
"{n} machine": "{n} máquina",
"{n} machines": "{n} máquinas"
}
To add a language:
- Copy
web/locales/es.jsontoweb/locales/<code>.json(ISO 639-1, e.g.fr) and translate the values. Keep{placeholders}untouched. - Add the code and its name to
LANGUAGESinweb/i18n.py. - Run
python3 scripts/check_i18n.py: it lists missing and unused strings.
es.d/-style per-feature files are supported for every language
(web/locales/<code>.d/*.json).
Branches and releases¶
Two long-lived branches:
| Branch | What it is | Receives | Releases | Images |
|---|---|---|---|---|
main |
The last release, always releasable | Merges of next at release time, and urgent fix/… |
v2.x.y tags |
:edge on push, :latest + :2.x.y on tags |
next |
Integration branch | PRs from work branches | -alpha.N, -beta.N, -rc.N tags |
:next on push, never :latest |
feat/…, fix/…, docs/…, chore/…, refactor/…, test/… |
Short-lived work branches | — | — | :dev and :branch-<name> on push |
Rules¶
- Branch from
nextand open the PR intonext. A fix that cannot wait for the next release branches frommaininstead, goes intomain, and is merged back intonext. Never fix a bug twice. mainandnextonly change through pull requests with green CI; no force-push, no deletion.- Work branches into
next: squash merge, with a Conventional Commit title (it becomes the commit message). - Keep work branches small (one task) and rebase them on their base branch freely while they are yours; once someone else uses one, merge instead.
CHANGELOG.md: write under the heading of the version in progress.- The
VERSIONfile holds the version of the code on that branch (web/version.pyand the image read it).
Releasing¶
- On a
release/x.y.zbranch, move the changelog entries to## [x.y.z] - YYYY-MM-DD, setVERSION, and the default tag incompose.yamlandARG HSE_VERSIONinaio/Dockerfile, with a PR intonext.python3 scripts/release_info.py check(also part ofscripts/validate.sh) fails when they differ. - Open a PR
next→mainand merge it with a merge commit, then tag the merge commitvx.y.z. On the tag, CI checks the tag againstVERSIONand the dated changelog entry, publishesx.y.z,x.y,xandlatestofheadscale-easy(and ofheadscale-easy-backup), pulls the published image back and runsscripts/compose-smoke.shon it, and creates the release page with the changelog entry as its notes. - Pre-releases are tagged on
next(v2.0.0-rc.1): they publish only their own tag, neverlatest.
Pull requests¶
- Fork and create a branch from
next(see Branches and releases). - Keep changes focused; update docs and translations in the same PR.
- Use Conventional Commits
(
feat: add tailnet lock page,fix(aio): …). - Describe what you tested. Screenshots help for UI changes.
- Say if the change was written with an AI assistant. That is fine — most of
this project was (see AI usage)
— but you must have read and tested it yourself, and changes to sign-in,
sessions or permissions need a test in
tests/test_security.py. - CI must pass.
By contributing you agree your work is licensed under the MIT License and to follow the Code of Conduct.