Skip to content

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.1 showed nodes pve, archlinux, and ts-router online.
  • With both nodes advertising the route, Headscale kept pve as serving primary and listed ts-router as approved and available.
  • After tailscale set --advertise-routes= on pve, Headscale showed ts-router as serving primary for 192.168.2.0/24; pve was approved but no longer available or serving.
  • ts-router reached 192.168.2.10 and 192.168.2.2 on the DMZ.
  • ts-router reached pve over a direct Tailscale path and archlinux over DERP.
  • The local archlinux client still had --accept-routes=false; a sudo attempt to enable accepted routes could not complete non-interactively in this workspace. Its ip route get 192.168.2.10 therefore used the local Wi-Fi gateway 192.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