Tailscale Exit Node on a Headscale-Managed VPS
Covers pre-rebuild backup, OS setup on a fresh VPS, Tailscale/Headscale registration as an exit node, and post-rebuild restore — across two separate hosts: a Headscale control-plane server and a VPS acting as the exit node.
<headscale-domain> (e.g. headscale.example.com),
<admin-user> (the sudo/Headscale user), and
<exit-node-host> (the VPS hostname).
Part A — Back up Tailscale state before rebuild
If the underlying VM survives (same provider account, just reinstalling the OS), you can preserve the node's existing Tailscale identity so it doesn't need to be re-registered in Headscale from scratch after the rebuild.
Stop tailscaled cleanly Run on: exit-node VPS
sudo systemctl stop tailscaled
Archive the state directory Run on: exit-node VPS
sudo tar czf ~/tailscale-state-backup-$(date +%Y%m%d).tar.gz -C /var/lib/tailscale .
Copy it off the VPS before wiping Run on: your local machine
# run this from your local machine, not the VPS scp <admin-user>@<exit-node-ip-or-hostname>:~/tailscale-state-backup-*.tar.gz ./
Part B — Rebuilding the VPS from scratch
Step 0 · Point apt at a local/regional mirror Run on: exit-node VPS
Some VPS providers ship a default sources.list pointing at a distant mirror regardless of where the VM is actually hosted. Ubuntu 24.04 uses the deb822 format by default.
ls /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list 2>/dev/null cat /etc/apt/sources.list.d/ubuntu.sources
Note the current mirror hostname, back up the file, then swap it for a mirror near the VPS's actual hosting location (not your own location):
sudo cp /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak sudo sed -i 's|<old-mirror-host>|<regional-mirror-host>|g' /etc/apt/sources.list.d/ubuntu.sources sudo apt update
Step 1 · Re-create the sudo user Run on: exit-node VPS
Only needed if the OS was fully reimaged. As root:
adduser <admin-user> usermod -aG sudo <admin-user> groups <admin-user> # confirm: <admin-user> sudo
Add your SSH public key:
mkdir -p /home/<admin-user>/.ssh chmod 700 /home/<admin-user>/.ssh nano /home/<admin-user>/.ssh/authorized_keys # paste your public key, save chown -R <admin-user>:<admin-user> /home/<admin-user>/.ssh chmod 600 /home/<admin-user>/.ssh/authorized_keys
ssh <admin-user>@<exit-node-host> sudo whoami # should return rootDon't harden SSH (disabling root login/password auth) until this is confirmed working.
Step 2 · Set the hostname Run on: exit-node VPS
Check whether cloud-init will silently revert a manual change:
grep preserve_hostname /etc/cloud/cloud.cfg
Set the hostname:
sudo hostnamectl set-hostname <exit-node-host>
Update /etc/hosts to match (replace the old hostname reference):
sudo nano /etc/hosts
# change to: 127.0.1.1 <exit-node-host>
Prevent cloud-init from reverting it on next boot:
sudo nano /etc/cloud/cloud.cfg
# set: preserve_hostname: true
Verify, then reboot to confirm it sticks:
hostname; hostname -f; cat /etc/hostname
sudo reboot
# reconnect, then:
hostname
Step 3 · Full system upgrade, without risking an SSH disconnect Run on: exit-node VPS
Hold openssh-server so its upgrade (and the sshd restart that comes with it) doesn't happen in the same pass as everything else:
sudo apt-mark hold openssh-server
Run the bulk upgrade inside tmux so a dropped connection doesn't kill it:
tmux new -s upgrade sudo apt update && sudo apt upgrade -y
If disconnected, reconnect and reattach:
ssh <admin-user>@<exit-node-host> tmux attach -t upgrade
Once the bulk upgrade finishes and the system is confirmed stable (reboot if a new kernel was installed — see Step 3a), upgrade openssh-server deliberately, on its own, also inside tmux:
tmux new -s ssh-upgrade sudo apt-mark unhold openssh-server sudo apt upgrade -y openssh-server
Reconnect fresh afterward and confirm SSH still works before closing anything else.
Step 3a · If a new kernel was installed Run on: exit-node VPS
Reboot and confirm before cleaning up old kernels:
sudo reboot # reconnect uname -r # confirm new kernel is running
Only then remove now-unneeded packages:
sudo apt autoremove -y sudo apt clean
Step 4 · Restore Tailscale state, or fresh-install Run on: exit-node VPS
If you have a backup from Part A (same VM, node identity preserved):
curl -fsSL https://tailscale.com/install.sh | sh sudo systemctl stop tailscaled sudo tar xzf ~/tailscale-state-backup-*.tar.gz -C /var/lib/tailscale sudo systemctl start tailscaled tailscale status
If the node reconnects showing its old hostname/IP with no login prompt, the identity carried over — skip to Step 8 (route re-verification) since registration already exists in Headscale.
If Headscale had expired/deleted the node, or tailscale status shows it needs auth, fall through to a fresh install below.
Fresh install (no backup, or backup didn't restore cleanly):
curl -fsSL https://tailscale.com/install.sh | sh
Step 5 · Enable IP forwarding Run on: exit-node VPS
sudo tee /etc/sysctl.d/99-tailscale.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding # both should = 1
Step 6 · Check the firewall Run on: exit-node VPS
sudo ufw status
Step 6a · Set up the Headscale-UI web interface (optional) Run on: Headscale server
headscale-ui gives you a graphical way to authorize nodes and approve routes instead of running docker exec commands. The UI itself has no way to create an API key — you have to generate one via CLI first and paste it in.
sudo docker exec -it headscale headscale apikeys create --expiration 90d
This prints a key like hskey-api-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX. Copy it immediately — Headscale doesn't display it again.
Open the UI in a browser:
https://<headscale-domain>/web/settings.html
Enter:
Headscale URL: https://<headscale-domain> API Key: the value you just generated
From here you can view registered nodes, authorize a pending node (equivalent to Step 7's auth register), approve exit-node routes (equivalent to Step 8), and rename/expire/delete nodes — all without touching the CLI.
Managing API keys going forward — run on the Headscale server:
sudo docker exec -it headscale headscale apikeys list
sudo docker exec -it headscale headscale apikeys expire --prefix <key-prefix>
Step 7 · Interactive login + registration
Run on: exit-node VPS
sudo tailscale up --login-server=https://<headscale-domain> --advertise-exit-node
Copy the hskey-authreq-... string from the output (no need to open the URL in a browser unless Headscale has OIDC configured).
If using the CLI instead of the UI from Step 6a, check existing users first — Run on: Headscale server
sudo docker exec -it headscale headscale users list
Register the node — --user first, --auth-id last, so the long auth-id string sits at the very end and is easy to paste in after typing the rest of the command. Run on: Headscale server
# Example: sudo docker exec -it headscale headscale auth register \ --user <admin-user> \ --auth-id hskey-authreq-XXXXXXXXXXXXXXXXX
Ready to paste (type/paste the command up through --auth-id, then paste the hskey-authreq-... string you copied from the VPS output right at the end):
sudo docker exec -it headscale headscale auth register --user <admin-user> --auth-id
nodes register --user --key is deprecated — use auth register --auth-id --user. Don't prepend nodekey: to the auth-id string; it's already included.
If a stale entry from a previous install exists under the old hostname/IP, clean it up — Run on: Headscale server
sudo docker exec -it headscale headscale nodes list
sudo docker exec -it headscale headscale nodes delete -i <old-node-id>
Step 8 · Verify and approve exit node routes
Run on: exit-node VPS
tailscale status tailscale ip -4
Get the node ID and approve both exit routes — Run on: Headscale server
sudo docker exec -it headscale headscale nodes list
sudo docker exec -it headscale headscale nodes approve-routes \
-i <node-id> -r 0.0.0.0/0,::/0
headscale routes subcommand was removed — route approval is now nodes approve-routes.Verify — run on the Headscale server:
sudo docker exec -it headscale headscale nodes list-routes
Step 9 · Use the exit node from a client device Run on: client device
tailscale exit-node list
sudo tailscale up --exit-node=<exit-node-host> --exit-node-allow-lan-access
Step 10 · Confirm traffic is egressing through the VPS Run on: client device
curl -4 ifconfig.me # should match the exit-node VPS's public IP
curl -6 ifconfig.me
Step 11 · Fix UDP GRO forwarding (throughput) Run on: exit-node VPS
sudo ethtool -K eth0 rx-udp-gro-forwarding on
Persist across reboots (netplan doesn't support this — use a systemd unit):
sudo tee /etc/systemd/system/eth0-offload.service <<'EOF' [Unit] Description=Enable UDP GRO forwarding on eth0 for Tailscale After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/sbin/ethtool -K eth0 rx-udp-gro-forwarding on RemainAfterExit=yes [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now eth0-offload.service
Verify:
ethtool -k eth0 | grep rx-udp-gro-forwarding sudo systemctl status eth0-offload.service
wireguard kernel module. Tailscale on Linux always uses its own userspace wireguard-go via /dev/net/tun — there's no code path that uses the kernel's native WireGuard module. The GRO/TSO offload above is the real lever on Linux.
Optional — Pinning a specific tailnet IP to a node
Headscale (as of 0.29.x) has no supported CLI command for this — a set-ip feature has been proposed upstream but isn't merged/released yet. The only known method is a direct SQLite edit, which is unsupported and carries real risk (Headscale's own docs warn direct DB edits "might have a severe impact"). Only do this if you understand and accept that.
Run on: Headscale server
cd /home/<admin-user>/headscale sudo docker compose stop headscale sudo cp lib/db.sqlite lib/db.sqlite.bak-$(date +%Y%m%d-%H%M) sudo sqlite3 lib/db.sqlite "UPDATE nodes SET ipv4='100.64.0.X', ipv6='fd7a:115c:a1e0::X' WHERE id=<node-id>;" sudo sqlite3 lib/db.sqlite "SELECT id, hostname, ipv4, ipv6 FROM nodes;" # verify before restarting sudo docker compose start headscale
Then on the affected client(s), force a reconnect to pick up the new IP:
Run on: the client (e.g. the exit-node VPS)
sudo tailscale down
sudo tailscale up --login-server=https://<headscale-domain> --advertise-exit-node
UPDATE statements, freeing the target IP before assigning it, to avoid a uniqueness collision.Quick reference — full command sequence (fresh install path)
# --- On the exit-node VPS, as root: create user --- adduser <admin-user> usermod -aG sudo <admin-user> mkdir -p /home/<admin-user>/.ssh && chmod 700 /home/<admin-user>/.ssh # paste public key into /home/<admin-user>/.ssh/authorized_keys chown -R <admin-user>:<admin-user> /home/<admin-user>/.ssh chmod 600 /home/<admin-user>/.ssh/authorized_keys # --- As <admin-user> --- # Step 0: apt mirror sudo sed -i 's|<old-mirror-host>|<regional-mirror-host>|g' /etc/apt/sources.list.d/ubuntu.sources sudo apt update # Step 2: hostname sudo hostnamectl set-hostname <exit-node-host> sudo sed -i 's/^preserve_hostname:.*/preserve_hostname: true/' /etc/cloud/cloud.cfg # Step 3: safe upgrade sudo apt-mark hold openssh-server tmux new -s upgrade sudo apt update && sudo apt upgrade -y # (reconnect/reattach if dropped: tmux attach -t upgrade) sudo reboot # reconnect, confirm uname -r, then: sudo apt autoremove -y && sudo apt clean tmux new -s ssh-upgrade sudo apt-mark unhold openssh-server sudo apt upgrade -y openssh-server # reconnect fresh, confirm ssh still works # --- Steps 4-7: Tailscale + Headscale registration, run on the exit-node VPS --- curl -fsSL https://tailscale.com/install.sh | sh sudo tee /etc/sysctl.d/99-tailscale.conf <<'EOF' net.ipv4.ip_forward = 1 net.ipv6.conf.all.forwarding = 1 EOF sudo sysctl -p /etc/sysctl.d/99-tailscale.conf sudo tailscale up --login-server=https://<headscale-domain> --advertise-exit-node # copy hskey-authreq-... from output # ============================================ # 🖥️ EVERYTHING BELOW THIS LINE RUNS ON THE HEADSCALE SERVER, NOT THE VPS # ============================================ sudo docker exec -it headscale headscale users list sudo docker exec -it headscale headscale auth register --user <admin-user> --auth-id <hskey-authreq-...> sudo docker exec -it headscale headscale nodes list sudo docker exec -it headscale headscale nodes approve-routes -i <node-id> -r 0.0.0.0/0,::/0 sudo docker exec -it headscale headscale nodes list-routes # ============================================ # 💻 BACK ON THE EXIT-NODE VPS FROM HERE # ============================================ # Throughput fix sudo ethtool -K eth0 rx-udp-gro-forwarding on # + systemd unit for persistence (see Step 11) # --- On any client device --- sudo tailscale up --exit-node=<exit-node-host> --exit-node-allow-lan-access curl -4 ifconfig.me