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(plusdb.sqlite-walanddb.sqlite-shmif 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 theheadscale-datavolume.
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_urldns.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, iffile)- the exact paths of
db.sqliteandnoise_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.
Path A - reuse the old URL (recommended, zero disruption)
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_urlis 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 inheadscale.yaml), or paste the HuJSON into the visual editor (database mode).
- API keys - preserved in the database, the remote
headscaleCLI keeps working if you turn onheadscale.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 statusstill shows connected with the same100.xIP and no re-login prompt. - MagicDNS names resolve (when
base_domainmatched).
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.