Installation

Migrating

Move an existing self-hosted headscale - its database, keys and server identity - onto headscale-admin without re-registering your nodes.

headscale-admin supervises its own headscale: it downloads the pinned release, generates and owns headscale.yaml, and keeps the database and keys under its work_dir. There is no mode that points it at a headscale you already run.

So migrating is not “connect headscale-admin to my server” - it’s handing your existing headscale’s data to the one headscale-admin manages. Done right, your nodes keep their IPs and keys and never have to re-authenticate.

Back up first

Stop your old headscale and copy its entire data directory somewhere safe before touching anything. The whole migration is reversible as long as you keep that backup and don’t change server_url.

What moves, what changes

Carries over (it’s in the data) Owned by headscale-admin (don’t bring it)
Users, nodes and their 100.x IPs, pre-auth keys, API keys, routes/exit nodes headscale.yaml - regenerated (loopback listeners, unix socket, gRPC)
ACL policy (when stored in the database) TLS - now terminated by headscale-admin’s front, not headscale
The server identity (noise_private.key) so clients keep trusting the control server Listen addresses, metrics/gRPC ports, trusted proxies

The two files that actually carry your tailnet:

  • db.sqlite (plus db.sqlite-wal and db.sqlite-shm if present - WAL mode)
  • noise_private.key

headscale-admin keeps them under its headscale.work_dir:

  • Manual / systemd / Ansible: whatever you set as headscale.work_dir (e.g. /opt/headscale-admin/data).
  • Docker / Compose: /data/headscale, on the headscale-data volume.

Before you start

Keep server_url reachable, not just identical

Set headscale.public_url to exactly your old server_url (same scheme, host and port) - it becomes headscale’s server_url and is what every node dials. It only works if headscale-admin’s front actually answers there, so its listen address (server.addr / HSA_SERVER_ADDR) must match that host:port too. Can’t keep the old URL? See Keeping the old address - any node moved to a new URL must re-run tailscale up --login-server ….

Version must be compatible

headscale-admin pins a headscale version (the one it downloads, shown on the Settings page). headscale’s database migrations are forward-only, so your standalone headscale must be at or below the pinned version. If yours is newer, wait for a headscale-admin release that pins your version - don’t downgrade the database.

PostgreSQL users

headscale-admin runs headscale on SQLite. If your standalone install used PostgreSQL, a file copy won’t work - you must convert the database to SQLite first. That conversion is outside this guide.

From your old config.yaml, note the values you’ll re-apply (paths vary - check your config, not these examples):

  • server_url
  • dns.base_domain (the MagicDNS suffix - must match to keep node DNS names)
  • prefixes.v4 / prefixes.v6 (only if you changed the defaults)
  • policy.mode (and the policy file path, if file)
  • the exact paths of db.sqlite and noise_private.key

Keeping the old address

public_url becomes headscale’s server_url and is what every node dials - so it only keeps working if headscale-admin’s front actually answers there. Pick the path that matches how you want to run it.

Point the old hostname at the new host and set the front’s listen address (server.addr / HSA_SERVER_ADDR) to the same host:port as your old server_url, with public_url set to it. That one port then serves the control plane, new nodes, and the admin UI (under /admin).

  • Old headscale already behind a reverse proxy on :443? Just send that hostname/proxy to headscale-admin instead - public_url is unchanged and there’s nothing else to do.
  • The UI now lives on that same port, even if it’s non-standard (e.g. https://hs.example.com:8080/admin). That’s expected.

Path B - move the control plane to a new port, still no disruption (transient)

Need the UI/front on a different port while existing nodes stay pinned to the old one? Run the front on the new port and put a tiny TCP (layer-4) passthrough on the old port forwarding to it. Because it’s raw TCP, the front still terminates TLS with its own cert - the proxy needs no certificate and you sidestep HTTP/2, DERP WebSocket and Noise (/ts2021) proxying pitfalls:

# top-level in nginx.conf - NOT inside http { }
stream {
    server {
        listen 3000;              # old server_url port
        proxy_pass 127.0.0.1:443; # headscale-admin front
    }
}

This is temporary scaffolding: remove it once every node has re-authed onto the new URL, or once you cut DNS over. (An L7/HTTP proxy works too, but then it must hold the old hostname’s cert and speak HTTP/2 + WebSocket upgrades - prefer the passthrough.)

Path C - clean break (re-auth nodes)

Don’t need continuity? Set public_url to the new URL and run the front wherever you like. Each existing node then re-runs the command from the Connect page (tailscale up --login-server <new-url> --reset --force-reauth). Identities and 100.x IPs still survive in the database - only the control URL each node points at changes.

Migrate — manual / systemd

# 1. Stop + back up the old server (paths from YOUR old config).
sudo systemctl stop headscale
sudo cp -a /var/lib/headscale /var/lib/headscale.bak

# 2. Install headscale-admin (see the Manual guide) with public_url = old server_url,
#    server.addr on that same host:port (see "Keeping the old address"), and your
#    TLS choice - but DON'T start it yet.

# 3. Put your data into headscale-admin's work_dir (create it if absent).
WD=/opt/headscale-admin/data           # = your headscale.work_dir
SVC=headscale-admin                     # = the user/group the service runs as
sudo install -d -o "$SVC" -g "$SVC" -m 0750 "$WD"
sudo install -o "$SVC" -g "$SVC" -m 0600 /var/lib/headscale/db.sqlite          "$WD/db.sqlite"
sudo install -o "$SVC" -g "$SVC" -m 0600 /var/lib/headscale/noise_private.key  "$WD/noise_private.key"
# WAL sidecars, only if they exist:
sudo cp -a /var/lib/headscale/db.sqlite-wal "$WD/" 2>/dev/null || true
sudo cp -a /var/lib/headscale/db.sqlite-shm "$WD/" 2>/dev/null || true
sudo chown -R "$SVC:$SVC" "$WD"

# 4. Start it. headscale-admin generates headscale.yaml, downloads the pinned
#    headscale, runs DB migrations up to that version, and serves your tailnet.
sudo systemctl start headscale-admin

Avoid a MagicDNS flap

The generated headscale.yaml ships headscale-admin’s default base_domain/prefixes. To serve your old values from the very first start: after step 4 systemctl stop headscale-admin, edit $WD/headscale.yaml (dns.base_domain, and prefixes if customised) to match your old config, then systemctl start again. Otherwise just fix them in Settings afterwards - headscale-admin restarts headscale to apply.

Migrate — Docker / Compose

work_dir is /data/headscale on the named volume. Inject the files while the container is stopped (the volume name is <project>_headscale-data, confirm with docker volume ls):

# Ensure the volume + work_dir exist (first run creates them), then stop.
docker compose up -d && docker compose down

# Copy your old data into the volume. uid 65532 = the nonroot image user.
docker run --rm \
  -v headscale-admin_headscale-data:/data \
  -v "$PWD/old-headscale:/src:ro" \
  busybox sh -c '
    cp /src/db.sqlite          /data/headscale/db.sqlite &&
    cp /src/noise_private.key  /data/headscale/noise_private.key &&
    cp -f /src/db.sqlite-wal /data/headscale/ 2>/dev/null;
    cp -f /src/db.sqlite-shm /data/headscale/ 2>/dev/null;
    chown -R 65532:65532 /data/headscale'

# Set public_url = old server_url in config.docker.yaml, then start.
docker compose up -d

Fix base_domain/prefixes in Settings after it’s up (or stop and edit headscale.yaml on the volume first, as in the tip above).

After the move

  • DNS / DERP / prefixes - confirm base_domain, nameservers and search domains in Settings. If you ran an embedded DERP server, re-enable it there (it’s off in the generated config).
  • ACL policy:
    • Stored in the database before → already migrated, nothing to do.
    • A file before → either keep policy.mode: file (set the path in headscale.yaml), or paste the HuJSON into the visual editor (database mode).
  • API keys - preserved in the database, the remote headscale CLI keeps working if you turn on headscale.expose_grpc.
  • Console admin - create the first headscale-admin admin (env bootstrap or admin create) and sign in.
  • TLS - retire your old reverse proxy / headscale TLS, headscale-admin’s front terminates it now. (Exception: under Path B you keep a small TCP passthrough on the old port until cutover.)

Verify

  • The UI lists your users, nodes, and routes.
  • On a client, tailscale status still shows connected with the same 100.x IP and no re-login prompt.
  • MagicDNS names resolve (when base_domain matched).

Rollback

Your old install is untouched and you have the backup. If anything looks wrong: systemctl stop headscale-admin (or docker compose down) and start the old headscale again. Because server_url never changed, clients flip straight back.

Note

headscale-admin can’t manage a headscale running elsewhere - it only manages the instance it supervises. Migration moves your data into that managed instance, the old process is then retired.