Runbook

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.

This is a sanitized, reusable version. Replace the placeholders before running anything: <headscale-domain> (e.g. headscale.example.com), <admin-user> (the sudo/Headscale user), and <exit-node-host> (the VPS hostname).
Don't have a Headscale server running yet? Start with the Headscale VPS Deployment runbook — this page assumes that control plane already exists.

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 ./
If the OS rebuild is destructive (fresh image, not an in-place reinstall), this backup is the only thing that lets the node resume its old identity. If Headscale expired or deleted the node in the meantime, this won't help — you'll need to re-register anyway (Part B, Steps 6–7).

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
Test in a new terminal before closing the root session:
ssh <admin-user>@<exit-node-host>
sudo whoami   # should return root
Don'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
Headscale automatically picks up the OS hostname the next time the node checks in — no manual rename needed on the Headscale side.

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>
Security note: treat the API key like a root password for Headscale. Don't paste it into shared docs, commit it to git, or leave it in shell history longer than needed. If a key is ever exposed somewhere it shouldn't be, expire it and generate a new one.

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
Headscale 0.29.x note: 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 0.29.x note: the old 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
Don't bother loading the 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
If swapping two nodes' IPs, do it in two 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