Two Tailscale Exit Nodes on One Box: a Fully Isolated Second Identity with Its Own WiFi AP

Jun 26, 2026 min read

I have a home server that already acts as a Tailscale exit node (call it exit node A) for my main tailnet. I wanted the same physical box to also offer a second, completely separate network: a WiFi access point where any device that joins gets internet as if it were sitting at a different location, by routing through a different Tailscale exit node (exit node B).

The catch is that you can’t just run two tailscaled instances side by side and expect them to coexist. This post walks through why, the network-namespace architecture that makes it work, and a fair amount of routing and WiFi-calling debugging along the way.

What this is

home-server is a Tailscale exit node (exit node A) for the main tailnet. This setup adds a second, fully isolated Tailscale identity on the same physical box that routes traffic through a different exit node (exit node B) instead of A’s own internet connection. A WiFi access point is bridged into that second identity, so any device that joins the AP gets internet access as if it were physically at exit node B’s location, extending B’s network presence to wherever this server sits.

Both identities run side by side without interfering with each other or with A’s normal exit-node service for the rest of the tailnet.

Why a second instance can’t just share routing with the primary

Tailscale programs Linux policy routing (ip rule plus a dedicated route table, normally table 52) to implement exit-node redirection. Two tailscaled processes in the same network namespace fight over the same table and rules: whichever started more recently silently overwrites the other’s routes for all tailnet peer IPs. I hit this directly during setup. Bringing up a second instance in the main namespace caused SSH (which travels over the primary’s tailscale0) to go dark, because the second instance clobbered table 52’s routes to every peer, including the primary’s own address.

The fix is to run the second tailscaled inside its own network namespace (apnet). Namespaces get their own private routing tables and rules, so the two instances never see each other’s state.

Architecture

                      ┌───────────────────────────────────────────────┐
                      │                 home-server                    │
                      │                                                │
 phone/laptop         │  wlan-ap (AP, hostapd)                         │
   joins wifi  ───────┼─▶ 10.55.10.1/24  ── dnsmasq DHCP .50-.150      │
 "extended-net-B"     │        │                                       │
                      │        │ ip rule: from 10.55.10.0/24 → table 200
                      │        ▼                                       │
                      │  veth-host 10.200.0.1/30 ── (main netns)       │
                      │        │                                       │
                      │  ══════╪══════════ netns boundary ═══════════  │
                      │        ▼                                       │
                      │  veth-ns 10.200.0.2/30  (netns: apnet)         │
                      │        │                                       │
                      │        │ ip rule pref 100: to 10.55.10.0/24 →  │
                      │        │   lookup main (else exit-node's       │
                      │        │   table-52 catch-all eats it)         │
                      │        ▼                                       │
                      │  tailscaled (2nd instance, state in            │
                      │  /var/lib/tailscale-apnet/)                    │
                      │  tailscale0 100.x.x.b                          │
                      │  exit-node = exit node B                       │
                      └───────────────────────────────────────────────┘

  Primary tailscaled (unchanged): main netns, state in
  /var/lib/tailscale/, tailscale0 100.x.x.a, advertises as
  exit node A for the rest of the tailnet. Totally separate from the above.

Traffic path for a device on the AP: device → hostapd/AP → host routes 10.55.10.0/24 traffic into the apnet netns via the veth pair → inside the netns, Tailscale’s exit-node routing sends it out through tailscale0 to exit node B → return traffic un-NATs back to the device’s AP IP, and a dedicated ip rule (priority 100) makes sure it routes back out the veth instead of looping back into the tunnel.

Components and where they live

ComponentPurposeLocation
hostapdRuns the WiFi AP/etc/hostapd/hostapd.conf, service hostapd.service
dnsmasq (dedicated instance)DHCP for AP clients/etc/dnsmasq.d/ap-extended-net-b.conf, service dnsmasq-ap.service
NetworkManager overrideStops NM from managing the AP’s WiFi adapter/etc/NetworkManager/conf.d/99-unmanage-ap-wifi.conf
Static IP for AP interfaceGateway address for AP subnet/etc/systemd/network/10-ap-wifi.network (via systemd-networkd)
netns + veth + routing setupWires the AP subnet into the isolated namespace/usr/local/sbin/setup-apnet-netns.sh, run by apnet-netns-setup.service (oneshot, boots before the 2nd tailscaled)
Second Tailscale instanceJoins tailnet as a second node, sets exit nodetailscaled-apnet.service, state in /var/lib/tailscale-apnet/, socket /run/tailscale/tailscaled-apnet.sock
DNS inside the namespacePublic resolvers (the netns can’t reach the host’s systemd-resolved stub)/etc/netns/apnet/resolv.conf
rtl8188eus driver (DKMS)Gives the WiFi adapter AP-mode support (stock rtl8xxxu doesn’t support AP mode on this chipset)DKMS module 8188eu/5.3.9, blacklist at /etc/modprobe.d/blacklist-rtl8xxxu.conf

All of the above services are systemctl enabled, so they come back up automatically on reboot, in the right order (apnet-netns-setuptailscaled-apnet).

Control-socket caveat: both tailscaled units originally declared the same RuntimeDirectory=tailscale (/run/tailscale). systemd recreates that directory on each unit’s start/stop, so the apnet instance, which starts after the primary on every boot, silently deleted the primary’s /run/tailscale/tailscaled.sock. Exit node A kept working (the socket is the local control API, not the data path), but the primary node was unmanageable via the tailscale CLI until restarted. Fixed with RuntimeDirectoryPreserve=yes drop-ins on both units. See “Primary node unmanageable / tailscaled.sock missing” under Troubleshooting.

Current configuration

  • WiFi adapter: TP-Link TL-WN722N v2/v3 (Realtek RTL8188EUS chipset). This chipset’s in-kernel rtl8xxxu driver does not support AP mode, confirmed via iw phy<N> info showing only managed/monitor. I built and installed the out-of-tree rtl8188eus driver via DKMS (from github.com/aircrack-ng/rtl8188eus) and blacklisted rtl8xxxu for this to take effect. 2.4GHz only (this card has no 5GHz radio).
  • SSID: extended-net-B
  • Password: set your own in hostapd.conf
  • Channel: 6 (2.4GHz)
  • Regulatory domain: US
  • AP subnet: 10.55.10.0/24, gateway 10.55.10.1
  • DHCP range: 10.55.10.5010.55.10.150
  • Exit node B: a remote desktop on a fixed broadband connection in a different country, on a separate tailnet account, switched to from a phone hotspot to rule out phone-hotspot instability as the cause of WiFi-calling flapping. Earlier, two different phones were used for testing.
  • veth point-to-point link: 10.200.0.1/30 (host) ↔ 10.200.0.2/30 (namespace)
  • Host-side policy routing table for AP subnet: table 200
  • Second Tailscale node name in tailnet: a dedicated apnet node name (100.x.x.b)
  • MTU: 1280 end-to-end (AP WiFi interface, both veth ends, and advertised to AP clients via DHCP option 26). See “MTU and WiFi calling” below for why.

MTU and WiFi calling

WiFi calling uses IPsec (IKEv2/ESP, typically NAT-T-encapsulated over UDP), which is intolerant of fragmentation and notoriously prone to being silently broken by PMTUD black holes through VPN/tunnel hops (intermediate routers dropping the ICMP “fragmentation needed” packets that would otherwise tell the sender to shrink its packets). Rather than rely on PMTUD working correctly through this path (host → AP → veth → netns → WireGuard hop → exit node B’s uplink), MTU is fixed at 1280 before packets ever reach the Tailscale-encapsulated hop:

  • The AP’s WiFi interface (wlan-ap) has its link MTU set to 1280 via /etc/systemd/network/10-ap-wifi.network ([Link] MTUBytes=1280).
  • dnsmasq advertises the same value to clients via DHCP option 26 (dhcp-option=26,1280 in /etc/dnsmasq.d/ap-extended-net-b.conf), so a connecting phone’s own IP stack natively fragments its IPsec/ESP payloads to fit before transmitting. It never has to discover the limit via ICMP.
  • Both ends of the veth pair (veth-host/veth-ns) are also set to 1280 MTU in /usr/local/sbin/setup-apnet-netns.sh (the AP_MTU variable), so the forwarding path inside the host can’t itself become the bottleneck.
  • Tailscale’s own tailscale0 interface already defaults to MTU 1280, so the entire chain is now consistently 1280 end-to-end with no fragmentation possible at any hop.

To change this value, edit AP_MTU in setup-apnet-netns.sh, the MTUBytes= line in 10-ap-wifi.network, and the dhcp-option=26,... line in the dnsmasq config, then run sudo /usr/local/sbin/setup-apnet-netns.sh, sudo systemctl restart systemd-networkd, and sudo systemctl restart dnsmasq-ap.

How to change things

Change SSID or password

sudo nano /etc/hostapd/hostapd.conf
# edit `ssid=` and/or `wpa_passphrase=`
sudo systemctl restart hostapd

Existing connected clients will be dropped and need to reconnect with the new password.

Change the AP subnet (e.g. away from 10.55.10.0/24)

Three places need to agree. They are not cross-referenced automatically:

  1. /etc/systemd/network/10-ap-wifi.network: change Address= (the .1 host address in the new subnet)
  2. /etc/dnsmasq.d/ap-extended-net-b.conf: change dhcp-range= and the dhcp-option=3,... gateway to match
  3. /usr/local/sbin/setup-apnet-netns.sh: change every occurrence of 10.55.10.0/24 (the host-side ip rule), and on the live system, also re-add the netns-side ip rule ... to <new-subnet> lookup main pref 100 to match (skipping this silently breaks return traffic again)

Then:

sudo systemctl restart systemd-networkd   # picks up new static IP
sudo systemctl restart dnsmasq-ap
sudo /usr/local/sbin/setup-apnet-netns.sh # reapplies routing with new subnet

Caution: restarting systemd-networkd has previously triggered Tailscale to lose and restore its own ip rules (it self-heals, but if the second tailscaled instance happens to be misconfigured at the same time, this can cascade into a full outage like I hit during initial setup). Prefer making this change at a time when you can verify SSH access immediately afterward, and have a LAN-IP fallback handy just in case.

Change exit node B

This is the lightest-weight change in the whole setup:

sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock \
  up --accept-dns=false --exit-node=<new-node-name-or-IP> \
  --exit-node-allow-lan-access=false --hostname=<apnet-node-name> --accept-routes

That’s it. No netns, veth, hostapd, or dnsmasq changes needed. Swapping exit node B is a single command since all the plumbing (NAT, the AP, the isolation) is keyed off the namespace, not the specific exit node.

Setting up a dedicated device as exit node B

Using a phone hotspot as exit node B is fine for testing, but a dedicated always-on device is much more stable. Steps:

  1. Get the new device onto the same tailnet (tailscale up on it, logging into the same account/tailnet).
  2. On that device, advertise it as an exit node: sudo tailscale up --advertise-exit-node (Linux), or enable the equivalent toggle in the Tailscale app for other OSes.
  3. If your tailnet has ACLs / exit-node approval requirements, approve it in the Tailscale admin console (Machines → that device → enable exit node).
  4. On home-server, run the one command from “Change exit node B” above, pointing --exit-node= at the new device’s tailnet name.
  5. Verify: sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock status should show active; exit node next to the new device. Then check a connected AP client actually gets that device’s public IP (e.g. visit ifconfig.me from a phone on extended-net-B).

No changes are needed to hostapd, dnsmasq, the netns, veth, or any routing rule. They’re all already keyed to “whatever the apnet instance’s current exit node is,” not to a specific device.

Troubleshooting

AP clients get an IP but no internet

Most likely the host-side NAT/routing chain is broken somewhere. Check in order:

# 1. Does the host know to route the AP subnet into the namespace?
ip rule show | grep 10.55.10.0/24          # expect: ... lookup 200
ip route show table 200                     # expect: default via 10.200.0.2 dev veth-host

# 2. Is the namespace's own exit-node routing not swallowing return traffic?
sudo ip netns exec apnet ip rule show       # expect a "pref 100 ... to 10.55.10.0/24 lookup main" ABOVE the "lookup 52" line

# 3. Is the 2nd tailscaled instance actually up with an exit node set?
sudo ip netns exec apnet tailscale --socket=/run/tailscale/tailscaled-apnet.sock status
# expect the exit node line to say "active; exit node"

# 4. Is NAT happening at both hops?
sudo iptables -t nat -L POSTROUTING -n -v | grep -E "10.200.0.0/30|tailscale0"
sudo ip netns exec apnet iptables -t nat -L POSTROUTING -n -v

If step 2’s rule is missing, you’ll see symptoms like: DNS/TCP requests visibly leave the phone (tcpdump -i wlan-ap), reach the namespace’s tailscale0 and get answered, but the answer never comes back out the AP interface. This is a routing loop where exit-node’s catch-all default route in table 52 grabs the return traffic before it gets a chance to go out via the veth back to the host. I hit exactly this during initial setup. The fix was the pref 100 rule from step 2.

Whole-server SSH/Tailscale outage after touching this config

This happened once during setup, root-caused to two compounding factors:

  1. A second tailscaled running in the main network namespace (not yet isolated) overwrote the primary’s routes in table 52, including routes to other tailnet peers. This is why I moved the second instance into its own netns.
  2. systemd-networkd (enabled for the AP’s static IP) flushes ip rules on its own startup/reload. Tailscale detects this and restores its rules automatically, but combined with #1 being active at the same time, the primary’s table 52 ended up incompletely reprogrammed.

If this happens again:

  • Don’t panic-reboot. First try sudo systemctl restart tailscaled (the primary one, not -apnet). This forces a full clean reprogram of its routing table and resolved it last time.
  • Fallback access: a direct LAN IP (not via Tailscale) works as long as your SSH key is in ~/.ssh/authorized_keys there, useful precisely when the Tailscale path itself is the thing that’s broken.

Primary node unmanageable / tailscaled.sock missing (shared RuntimeDirectory)

Symptom: tailscale status (or any tailscale CLI command) on the primary node fails with failed to connect to local tailscaled ... /run/tailscale/tailscaled.sock: connect: no such file or directory, even though systemctl status tailscaled shows it active (running) and exit node A is still serving traffic normally. (Only /run/tailscale/tailscaled-apnet.sock is present in the directory.)

Root cause: tailscaled.service and tailscaled-apnet.service both declared RuntimeDirectory=tailscale, i.e. they shared /run/tailscale. systemd does not reference-count shared runtime directories. It removes and recreates the directory whenever either unit starts or stops, destroying the other daemon’s socket. Because the apnet unit waits on network-online.target plus apnet-netns-setup, it starts after the primary on every boot and wiped the primary’s tailscaled.sock each time. The breakage is silent: the running daemon keeps its in-kernel routes and its control-plane connection, so exit node A and all routing keep working. Only local CLI management of the primary is lost.

Fix: RuntimeDirectoryPreserve=yes drop-ins on both units, so neither wipes /run/tailscale on start/stop. Each unit’s ExecStopPost ... --cleanup only removes its own socket, so they no longer interfere.

# drop-in files (identical contents):
#   /etc/systemd/system/tailscaled.service.d/10-preserve-runtime-dir.conf
#   /etc/systemd/system/tailscaled-apnet.service.d/10-preserve-runtime-dir.conf
#       [Service]
#       RuntimeDirectoryPreserve=yes
sudo systemctl daemon-reload          # neutralizes the bug for all future start/stop, incl. reboot
# then restart the primary ONCE to recreate its now-missing socket. This briefly
# drops exit node A and any Tailscale-path SSH, so run it from the LAN fallback:
sudo systemctl restart tailscaled
ls -la /run/tailscale                 # verify BOTH tailscaled.sock and tailscaled-apnet.sock coexist

WiFi calling registers then drops repeatedly (“emergency calls only”)

Diagnosed via a 4-point IKE/IPsec packet capture (UDP 500/4500) plus phone-side adb logcat -b radio,main while toggling WiFi calling. Findings:

  • The IKEv2 handshake to the carrier’s ePDG completes normally at the network level. IKE_SA_INIT and IKE_AUTH round-trips both get clean responses, no packet loss, no NAT port instability. This rules out the AP/veth/netns/Tailscale chain as the cause.
  • Phone-side logs (RegiMgr: notifyImsRegistration: isVoWiFiRegistered) show the registration actually succeeds, but then flaps: registered → deregistered ~50-60s later → re-registered ~5-10s after that, repeating. If you check the WFC toggle during a “deregistered” window it looks permanently broken, but it’s cycling.
  • Most likely cause: a phone used as the exit node has a much less stable connection than a dedicated device. Its own mobile/hotspot NAT mapping or signal can blip, which breaks the AP client’s existing IPsec session and forces IMS to re-register. This is expected to improve once exit node B is a dedicated always-on device.
  • A vendor-internal wfcErrorCode is cached in the WFC settings UI during drops, not a standard 3GPP IKE notify code, no public decode table. Not pursued further since the flapping pattern already explains the symptom.

WiFi calling with Airplane Mode on (to suppress cellular registration)

Tested as an attempt to force the SIM off the cellular/roaming network entirely and rely solely on VoWiFi over extended-net-B. Result: does not work for this SIM/carrier, and fails differently depending on timing:

  • First attempt: the IKE handshake to the ePDG never completed. IKE_AUTH request sent, no response within 10s (ERIS_NO_IKE_AUTH_RESPONSE), then a retry’s IKE_SA_INIT itself got no response at all (ERIS_NO_IKE_SA_INIT_RESPONSE).
  • Second attempt: the handshake got further. IKE_SA_INIT completes, IKE_AUTH exchanges the EAP-AKA challenge/response, but the ePDG explicitly rejects it every time: parsed IKE_AUTH response ... [ EAP/SUCC N(AUTH_FAILED) ]AUTHENTICATION_FAILED. This repeats on a ~13-15s retry cycle, which is why the WFC toggle visibly “turns itself off” about 10 seconds after being enabled. It’s retrying and getting rejected, not hanging.
  • Conclusion: this looks like a hard authentication rejection by the carrier’s network, not a transport/MTU/routing issue (the handshake reaches the EAP-AKA step cleanly). Most likely explanation: the carrier’s AAA/ePDG requires the SIM to have an active cellular registration to validate the EAP-AKA exchange for VoWiFi, as an anti-fraud check. With Airplane Mode on there’s no cellular attach, so every authentication attempt is denied regardless of the WiFi/Tailscale path’s health.
  • Takeaway: don’t rely on Airplane Mode + WiFi calling as a way to avoid the cellular-roaming/IP-location mismatch on this SIM. It doesn’t get far enough to be useful.

WiFi calling rejected with INTERNAL_ADDRESS_FAILURE (dedicated desktop exit node)

Re-tested after switching exit node B from a phone to a desktop on a fixed broadband connection, specifically to rule out the phone-hotspot-instability cause of the flapping above. Result: a different, more specific failure, diagnosed via phone-side adb logcat -b radio,main plus a fresh 4-point IKE/IPsec packet capture.

  • Phone-side: IKE_SA_INIT succeeds, IKE_AUTH completes the full EAP-AKA exchange cleanly (EAP/SUCC, authentication itself succeeds, unlike the Airplane Mode case above), but the final AUTH message gets rejected with N(INT_ADDR_FAIL) (INTERNAL_ADDRESS_FAILURE), a standard IKEv2 notify meaning the ePDG would not assign an internal tunnel IP. This repeats on a tight ~2-3s retry cycle.
  • The phone alternated between two different ePDG IPs in the same carrier range, both rejected identically, ruling out one flaky ePDG server.
  • The 4-point packet capture (AP WiFi, veth-host, veth-ns, tailscale0 in the netns) showed fully clean, symmetric traffic at every hop: same packet count and sequence end-to-end, no drops, no asymmetry. The IKE exchange completes and the carrier’s own INFORMATIONAL teardown follows immediately after the rejection. This is a deliberate rejection by the carrier, not a network/path failure on my side.
  • Conclusion: this looks like a carrier-side policy rejection (most likely IP-reputation/geofencing-related, given it’s tied to the new exit node’s location/IP rather than a transport issue), not something fixable in this architecture. It’s a different failure mode than the phone-hotspot flapping: that one was registration succeeding then degrading due to a flaky exit node; this one is the carrier outright refusing to complete the tunnel setup from this particular exit node’s IP, consistently, across multiple of their own ePDG servers.
  • Decision: stop pursuing WiFi calling through this AP for now. The carrier appears to actively reject VoWiFi from this IP/location rather than just being unreliable about it.

Checking AP client connections

cat /var/lib/misc/dnsmasq.leases          # see who's connected + their IP
sudo systemctl status hostapd dnsmasq-ap tailscaled-apnet apnet-netns-setup

Known limitations

  • The WiFi adapter is 2.4GHz-only (no 5GHz radio on this hardware).
  • A desktop exit node avoids the offline/battery issues of phone-based exit nodes, but WiFi calling specifically does not work through it. See “WiFi calling rejected with INTERNAL_ADDRESS_FAILURE” above.
  • DNS for AP clients is currently hardcoded to 1.1.1.1/8.8.8.8 rather than the netns’s tailnet DNS. This was a deliberate simplification, since the netns can’t reach the host’s systemd-resolved stub resolver (127.0.0.53 only listens in the host’s own network namespace).

Control-plane ACL

The tailnet’s exit-node access is gated by a Tailscale ACL. Roughly (emails redacted):

{
  "tagOwners": {
    "tag:admin":           ["you@example.com"],
    "tag:trusted-devices": ["you@example.com"],
    "tag:guest-devices":   ["you@example.com"],
    "tag:exit-nodes":      ["you@example.com"]
  },

  "grants": [
    // Admin devices: full access to everything, including exit-node egress
    {
      "src": ["tag:admin"],
      "dst": ["*", "autogroup:internet"],
      "ip":  ["*"]
    },
    // Trusted devices: only SSH + media server on the box, plus exit-node egress
    {
      "src": ["tag:trusted-devices"],
      "dst": ["media-server", "autogroup:internet"],
      "ip":  ["tcp:22", "tcp:8080"]
    },
    // Guest devices: public internet only (any exit node egress)
    {
      "src": ["tag:guest-devices"],
      "dst": ["autogroup:internet"],
      "ip":  ["*"]
    }
  ],

  "autoApprovers": {"exitNode": ["tag:exit-nodes"]}
}

The takeaway from the whole project: once the second tailscaled lives in its own network namespace, everything else (the AP, NAT, the routing rules) is keyed to the namespace rather than to any specific exit node, so swapping where your “second location” lives becomes a one-line command.