Tailscale and Headscale Client Onboarding
Updated: 2026-07-01
This runbook describes the standard way to install the Tailscale client on KH3
infrastructure hosts and join them to the self-hosted Headscale control plane at
https://vpn.kh3group.com.
What Joining Means
Joining a machine to https://vpn.kh3group.com means the Tailscale client on
that machine registered with the KH3 Headscale control plane. Headscale is the
coordination server: it records users, nodes, keys, policy, DNS settings, and
the information clients need to find each other. The encrypted data plane is
still Tailscale/WireGuard between nodes, direct peer-to-peer when possible and
relayed when needed.
This is VPN-like, but it is not the same as a Cloudflare tunnel and it does not automatically expose every local LAN IP behind the joined host.
What a joined node can normally reach:
- other nodes that are joined to the same KH3 tailnet, by their tailnet IPs;
- services listening on those joined nodes, subject to host firewalls and future Headscale policy;
- local services on the same node through that node's own tailnet address.
What a joined node cannot reach by default:
- arbitrary
192.168.x.x,10.x.x.x, or other LAN devices behind another node; - the whole Proxmox/LAN subnet through
pve; - the public internet through a KH3 node.
To reach non-Tailscale LAN resources through the mesh, configure a deliberate
subnet router. A subnet router advertises one or more LAN routes, for example
192.168.1.0/24, from a stable machine such as pve. That should be treated as
a separate network design change because it affects routing, access control,
return paths, and firewall policy. To route all internet traffic through a KH3
node, configure an exit node; that is also a separate design change and is not
enabled by default.
Standard Pattern
Use the official Tailscale package repository for hosts with apt, not
curl | sh. This keeps the install auditable, repeatable, and updateable by the
normal package manager.
Use Headscale users by machine class:
| User | Purpose |
|---|---|
infra |
Infrastructure servers, Proxmox hosts, future subnet routers |
| Human OIDC users | Headplane administration and personal/user-owned devices |
Do not enroll infrastructure nodes under a human OIDC user unless there is a specific reason. A service identity keeps long-lived server lifecycle separate from a person's account.
Use short-lived pre-auth keys for enrollment. The key expiration controls only how long the enrollment credential can be used. It does not expire or disconnect the node after it joins. Once joined, the node has its own machine key.
Client defaults for KH3:
| Setting | Default | Reason |
|---|---|---|
--login-server |
https://vpn.kh3group.com |
Use KH3 Headscale, not Tailscale SaaS or the retired vpn.jaranor.com mesh |
--accept-dns |
false for servers and initial Linux workstation joins |
Avoid unexpected resolver changes during enrollment |
--accept-routes |
false unless the client needs LAN access through an approved subnet router |
Avoid silently using subnet routes before they are designed |
--ssh / RunSSH |
false |
Keep SSH access under the existing SSH model unless intentionally changed |
| advertised routes | none except approved subnet routers | Subnet routers require explicit design and validation |
Proxmox Host Baseline
The first enrolled host is:
host: pve
connect alias: ssh pvessh
OS: Debian 13 trixie
platform: Proxmox VE 9.2
Tailscale version: 1.98.8
Headscale owner: infra
Tailnet IPv4: 100.64.0.1
Tailnet IPv6: fd7a:115c:a1e0::1
LAN IP: 192.168.2.10/24 on vmbr0.2
LAN subnet: 192.168.2.0/24
LAN gateway: 192.168.2.1
ssh pvessh connects as root on the Proxmox node. Do not use sudo in commands
run through this alias; the minimal Proxmox host does not have sudo installed.
Install Tailscale on Debian 13
Run on the target host as root:
mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg \
| tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list \
| tee /etc/apt/sources.list.d/tailscale.list >/dev/null
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y tailscale
systemctl enable --now tailscaled
tailscale version
systemctl is-active tailscaled
For other Debian or Ubuntu releases, use the matching distribution path from
https://pkgs.tailscale.com/stable/.
Create the Infrastructure User
Run on ovps-me:
sudo -iu podsvc podman exec headscale \
headscale users create infra \
--display-name "Infrastructure Servers" \
--email "[email protected]"
This only needs to be done once. Confirm it exists:
sudo -iu podsvc podman exec headscale headscale users list
Generate a Pre-Auth Key
Generate a short-lived key for the infra user. Do not print or commit the
key.
sudo -iu podsvc podman exec headscale \
headscale preauthkeys create \
--user <infra-user-id> \
--expiration 24h
Use reusable keys only when enrolling a batch of machines in a controlled window. Prefer one key per host for auditability.
Join a Host
For Proxmox and other infrastructure hosts, disable DNS acceptance so the mesh does not unexpectedly change resolver behavior:
tailscale up \
--login-server=https://vpn.kh3group.com \
--auth-key=<preauth-key> \
--hostname=<hostname> \
--accept-dns=false
Do not enable subnet routing, exit-node behavior, or Tailscale SSH by default. Those should be separate, explicit changes.
Dedicated Subnet Router
The active KH3 subnet router is CT 105 ts-router, a small unprivileged Debian
13 LXC on Proxmox. It replaced pve as the serving subnet router on
2026-07-01.
Proxmox guest: CT 105 ts-router
guest type: unprivileged Debian 13 LXC
tailnet owner: infra
tailnet IP: 100.64.0.3
tailnet IPv6: fd7a:115c:a1e0::3
DMZ interface: eth0 on vmbr0, VLAN tag 2
DMZ address: 192.168.2.120/24 by DHCP
DMZ gateway: 192.168.2.1
DNS resolver: 192.168.2.2 via dhclient supersede
advertised route: 192.168.2.0/24
Headscale route state: approved and serving
SNAT mode: Tailscale default enabled
SNAT is intentionally left enabled. LAN devices see traffic as coming from
ts-router's DMZ address instead of from 100.64.0.0/10, which avoids
changing LAN gateway return routes while the mesh is being introduced.
Forwarding is required on ts-router and is persisted in
/etc/sysctl.d/99-tailscale-subnet-router.conf:
printf "%s\n" \
"net.ipv4.ip_forward=1" \
"net.ipv6.conf.all.forwarding=1" \
> /etc/sysctl.d/99-tailscale-subnet-router.conf
sysctl -p /etc/sysctl.d/99-tailscale-subnet-router.conf
The Tailscale settings on ts-router are:
tailscale up \
--login-server=https://vpn.kh3group.com \
--hostname=ts-router \
--accept-dns=false \
--advertise-routes=192.168.2.0/24
Approve the route from ovps-me:
sudo -iu podsvc podman exec headscale \
headscale nodes approve-routes \
-i 3 \
-r 192.168.2.0/24
Verify from ovps-me:
sudo -iu podsvc podman exec headscale headscale nodes list-routes
Expected route state:
Hostname: ts-router
Approved: 192.168.2.0/24
Available: 192.168.2.0/24
Serving (Primary): 192.168.2.0/24
Verify from ts-router:
ssh pvessh 'pct exec 105 -- sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding'
ssh pvessh 'pct exec 105 -- tailscale debug prefs'
ssh pvessh 'pct exec 105 -- ping -c 2 192.168.2.10'
ssh pvessh 'pct exec 105 -- ping -c 2 192.168.2.2'
Expected values:
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
ControlURL: https://vpn.kh3group.com
Hostname: ts-router
CorpDNS: false
RunSSH: false
AdvertiseRoutes: 192.168.2.0/24
NoSNAT: false
Former Bootstrap Router
pve was the bootstrap subnet router for 192.168.2.0/24 and remains enrolled
as an infrastructure node:
host: pve
tailnet owner: infra
tailnet IP: 100.64.0.1
LAN address: 192.168.2.10/24 on vmbr0.2
current advertised routes: none
Headscale route state: route remains approved but not available or serving
Verify from pve:
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
tailscale debug prefs | grep -E 'ControlURL|Hostname|CorpDNS|RunSSH|AdvertiseRoutes|NoSNAT'
Expected current values:
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
ControlURL: https://vpn.kh3group.com
Hostname: pve
CorpDNS: false
RunSSH: false
AdvertiseRoutes: null
NoSNAT: false
Rollback path: re-advertise the route on pve with
tailscale set --advertise-routes=192.168.2.0/24 and verify Headscale route
state. The pve forwarding sysctl file was intentionally left in place after
retirement.
2026-07-01 Migration Validation
Validation performed during the migration:
- Headscale
v0.29.1showed nodespve,archlinux, andts-routeronline. - With both nodes advertising the route, Headscale kept
pveas serving primary and listedts-routeras approved and available. - After
tailscale set --advertise-routes=onpve, Headscale showedts-routeras serving primary for192.168.2.0/24;pvewas approved but no longer available or serving. ts-routerreached192.168.2.10and192.168.2.2on the DMZ.ts-routerreachedpveover a direct Tailscale path andarchlinuxover DERP.- The local
archlinuxclient still had--accept-routes=false; a sudo attempt to enable accepted routes could not complete non-interactively in this workspace. Itsip route get 192.168.2.10therefore used the local Wi-Fi gateway192.168.0.1, not the tailnet route.
Join a Linux Workstation Interactively
Use a human OIDC user for personal or workstation devices. Do not use the
infra pre-auth-key flow for a user's laptop or desktop unless the machine is
being treated as an always-on infrastructure asset.
If the workstation is not already joined to another mesh:
sudo tailscale up \
--login-server=https://vpn.kh3group.com \
--hostname=<hostname> \
--accept-dns=false
Open the registration URL shown by the CLI and authenticate with the KH3 domain
OIDC account. After successful auth, the CLI prints Success. and the machine
is registered in Headscale.
If the workstation is already joined to another Headscale/Tailscale profile, see
Linux profile switching before running tailscale
up.
Use the PVE LAN Route from a Linux Client
After pve is advertising and serving 192.168.2.0/24, a Linux client must
accept subnet routes before it can use that LAN path. Keep DNS disabled unless
KH3 MagicDNS/resolver behavior is being intentionally changed.
Generic command for a Linux workstation already joined to KH3:
sudo tailscale up \
--login-server=https://vpn.kh3group.com \
--hostname=<hostname> \
--accept-dns=false \
--accept-routes
If the CLI complains that changing settings requires all non-default flags,
include every flag shown in the error output. Do not add --reset unless the
goal is to reset unspecified settings to defaults.
Verify on the Linux client:
sudo tailscale debug prefs | grep -E 'ControlURL|Hostname|CorpDNS|RouteAll|RunSSH'
ip route get 192.168.2.10
ping -c 3 192.168.2.10
Expected:
ControlURL: https://vpn.kh3group.com
CorpDNS: false
RouteAll: false
RunSSH: false
192.168.2.10 is reached through tailscale0
Use LAN-service checks after the route works, for example:
curl -kI https://192.168.2.10:8006/
Verify
On the client:
systemctl is-active tailscaled
tailscale status
tailscale ip
tailscale debug prefs
Expected for the Proxmox host:
ControlURL: https://vpn.kh3group.com
Hostname: pve
CorpDNS: false
RunSSH: false
AdvertiseRoutes: 192.168.2.0/24
On ovps-me:
sudo -iu podsvc podman exec headscale headscale nodes list
Expected for pve:
Hostname: pve
User: infra
Connected: online
Expired: no
For a workstation, confirm the active control plane and profile:
sudo tailscale switch --list
sudo tailscale status
sudo tailscale debug prefs | grep -E 'ControlURL|Hostname|CorpDNS|RunSSH'
Expected for the local archlinux workstation after joining KH3:
ControlURL: https://vpn.kh3group.com
Hostname: archlinux
CorpDNS: false
RunSSH: false
Linux Profile Switching
Linux clients without a GUI still support multiple Tailscale profiles through the CLI. Only one profile is active at a time in the normal client setup. Switching profiles disconnects the host from the current mesh and reconnects it to the selected one.
Observed local starting state before joining KH3:
active profile: vpn.jaranor.com
account: [email protected]
profile id: 1c40
ControlURL: https://vpn.jaranor.com
CorpDNS: true
RunSSH: false
The old profile can be restored with:
sudo tailscale switch 1c40
When changing --login-server, Tailscale requires explicit reauthentication. If
the client says that changing settings requires mentioning all non-default flags,
either include the shown flags or use --reset. If it then says:
can't change --login-server without --force-reauth
use:
sudo tailscale up \
--login-server=https://vpn.kh3group.com \
--hostname=archlinux \
--accept-dns=false \
--reset \
--force-reauth
This opens a KH3 Headscale registration URL. Authenticate through OIDC. A final
Success. means the workstation joined the KH3 tailnet.
Do not assume both meshes are active at once. If simultaneous connectivity to
two meshes is required, use a separate VM/container or a deliberately engineered
second tailscaled instance with separate state, socket, and network interface.
That is not the standard KH3 client pattern.
Pitfalls and Corrections
Do Not Use sudo on pvessh
ssh pvessh lands as root. Commands containing sudo fail with:
bash: line 1: sudo: command not found
Remove sudo for Proxmox host commands through pvessh.
Local SSH Client Config Error
Some sandboxed local SSH invocations failed with:
Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf
The same SSH target worked in the normal unsandboxed path. Do not spend time debugging the remote host when this appears before connection; it is a local SSH client config ownership issue in the execution environment.
Short-Lived Keys Do Not Expire Joined Nodes
A --expiration 24h pre-auth key means the key must be used within 24 hours.
It does not mean the enrolled node needs reauthorization every day. Check
Headscale's node list for the node expiration state.
Do Not Enroll Servers as Human Users
Human OIDC users are for administration and personal devices. Use infra for
servers so future policy, audit, and lifecycle decisions are not tied to a
person's account.
Do Not Enable DNS on Proxmox by Default
Use --accept-dns=false on Proxmox and infrastructure hosts unless a change
explicitly requires MagicDNS resolver changes. Proxmox host DNS affects guest
management and operational access.
Do Not Advertise Routes Casually
Subnet routing is how this mesh can eventually replace Cloudflare tunnels, but it should be designed deliberately. Pick a small number of stable subnet router nodes, document advertised routes, and validate return routing before depending on them.
The currently approved route is deliberately narrow: pve serves only
192.168.2.0/24. Do not advertise broader private ranges such as 10.0.0.0/8,
172.16.0.0/12, or 192.168.0.0/16 without a separate design decision.
Joining a Node Is Not a LAN Tunnel by Itself
Installing Tailscale and joining KH3 Headscale gives the machine a tailnet identity and tailnet IP. It does not automatically make the machine a gateway to its local LAN. For local-resource replacement of Cloudflare tunnels, document which services should move first, decide whether each target should run Tailscale directly or be reached through a subnet router, and then configure routes intentionally.
Keep DNS Disabled Until Needed
CorpDNS: true means the client is accepting DNS settings from the control
plane. For the retired vpn.jaranor.com profile this was enabled on the local
workstation. For KH3 onboarding, use --accept-dns=false first, verify the mesh,
and only enable DNS once the KH3 MagicDNS and resolver behavior is intentionally
documented.
References
- Tailscale Linux install docs
- Tailscale stable package repository
- Tailscale CLI reference:
up,switch,debug, and non-default flag behavior - Tailscale fast user switching / profiles
- Tailscale DNS behavior and MagicDNS concepts
- Tailscale subnet routers: when LAN resources should be reached through a gateway
- VPS Headscale and Headplane