Installation

Docker Compose

A self-contained Compose service that builds the image, persists everything to a named volume, and supervises headscale.

The repo ships a complete, ready-to-run Compose example at examples/docker-compose/. By default it pulls the published image, runs the container as nonroot, and keeps all state - sqlite DB, generated headscale.yaml, keys, socket, and the downloaded headscale binary - on a named headscale-data volume, so recreating the container keeps your network, nodes, and keys intact.

Quick start

cd examples/docker-compose
cp .env.example .env          # then edit: set HSA_AUTH_SESSION_SECRET
$EDITOR config.docker.yaml    # set headscale.public_url + your TLS mode
docker compose up -d          # pulls ghcr.io/yousysadmin/headscale-admin
# open https://<your-host>/admin

Build from source instead? Comment out image: in docker-compose.yaml, uncomment the build: block, then docker compose up -d --build (builds the frontend + binary end to end). --build is only needed the first time or after a code change.

The three files

.env - the required secret plus the first-admin seed (Compose fails fast if HSA_AUTH_SESSION_SECRET is unset):

HSA_AUTH_SESSION_SECRET=<openssl rand -hex 32>
# First-admin bootstrap: seeded on first start (see "Create the first admin").
HSA_AUTH_BOOTSTRAP_ADMIN_EMAIL=[email protected]
HSA_AUTH_BOOTSTRAP_ADMIN_PASSWORD=<at least 8 chars>
# Only if you enable OIDC in config.docker.yaml:
# HSA_AUTH_OIDC_CLIENT_SECRET=...

config.docker.yaml - mounted read-only at /config.yaml (the image’s default --config path). The important knobs:

server:
  addr: :3000
  db_path: /data/admin.db
  tls:
    mode: none               # plain HTTP behind your own TLS proxy, or:
    # mode: acme             # built-in Let's Encrypt (see the port note below)
    # acme:
    #   email: [email protected]
    #   hosts: [hs.example.com]
    #   http_addr: :80
    #   cache_dir: /data/certs

auth:
  disabled: false            # session_secret comes from .env
  oidc:
    enabled: false

headscale:
  work_dir: /data/headscale  # everything persistent lives here (the volume)
  public_url: https://hs.example.com   # MUST match how clients connect

docker-compose.yaml - the service itself (abbreviated):

services:
  headscale-admin:
    image: ghcr.io/yousysadmin/headscale-admin:latest
    # Build from source instead? Comment out `image:` and uncomment:
    # build: { context: ../.., dockerfile: examples/docker-compose/Dockerfile }
    restart: unless-stopped
    ports:
      - "3000:3000"
      # Built-in ACME TLS instead? Publish the standard ports:
      # - "443:3000"
      # - "80:80"
    environment:
      HSA_AUTH_SESSION_SECRET: ${HSA_AUTH_SESSION_SECRET:?set in .env}
      HSA_AUTH_BOOTSTRAP_ADMIN_EMAIL: ${HSA_AUTH_BOOTSTRAP_ADMIN_EMAIL:-}
      HSA_AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${HSA_AUTH_BOOTSTRAP_ADMIN_PASSWORD:-}
    volumes:
      - ./config.docker.yaml:/config.yaml:ro
      - headscale-data:/data
    stop_grace_period: 30s
volumes:
  headscale-data:

First start needs outbound internet to download + verify the pinned headscale binary (cached on the volume afterwards). A named volume is used on purpose - the nonroot image (uid 65532) seeds /data with the right ownership; a host bind mount would need a manual chown.

Create the first admin

With auth enabled, the admin surface needs at least one admin. The container seeds it for you on first start from the environment - no second command, and no clashing with the running server over the database lock:

# in .env
HSA_AUTH_BOOTSTRAP_ADMIN_EMAIL=[email protected]
HSA_AUTH_BOOTSTRAP_ADMIN_PASSWORD=<at least 8 chars>   # omit when OIDC is enabled

serve creates a pinned super-admin with these credentials on startup if no account with that email exists yet. It’s idempotent - it never overwrites an existing account, so you can leave the values in .env or remove them after the first boot. Then sign in at /admin (email + password; with OIDC enabled the matching same-email IdP account signs in).

Changing HSA_AUTH_BOOTSTRAP_ADMIN_PASSWORD later does not reset an existing admin’s password - use the Admins page in the UI, or account self-service, for that.

Day-two

docker compose logs -f
docker compose pull && docker compose up -d   # upgrade to a newer image
docker compose down                           # stop (the headscale-data volume persists)

(Building from source? Use docker compose up -d --build to pick up code changes instead.)

  • Upgrade headscale from the Settings page (pinned + checksum-verified) - not by editing Compose.
  • Back up the headscale-data volume, or use the Backup page for consistent exports.