Run Tailscale as a subnet router behind a UniFi Gateway using two
redundant Debian VMs. FRR's bgpd peers with the UniFi Gateway over eBGP; BGP path
attributes pick one VM as active and one as standby, and failover is
automatic when the active VM drops its session.
This repo used to document a pfSense + OSPF design. It has been rewritten end-to-end for Debian + FRR + eBGP. If you need the old guide, check out a commit before this rewrite — git history is intact.
ts-home1(active) andts-home2(standby) are Debian 13 (Trixie) VMs running on separate Proxmox hosts.ens18is the WAN interface on VLAN 220 (DHCP). Tailscale uses this to reach the tailnet.ens19is the inside interface on VLAN 221, a /24 BGP transit subnet. The UniFi Gateway peers with each VM across this VLAN.- The VMs are AS 65001; the UniFi Gateway is AS 65000. eBGP exchanges
exactly two aggregates:
192.168.0.0/16(LAN) and100.64.0.0/10(Tailscale CGNAT). - The Tailscale daemon installs per-peer
/32routes inside the100.64.0.0/10aggregate.iptablesmasquerades LAN traffic to the VM's Tailscale IP on the way out so peers that haven't accepted your subnet routes can still send return traffic.
| Direction | Prefix(es) | Modifier | Effect |
|---|---|---|---|
ts-home1 → UniFi Gateway |
100.64.0.0/10 |
default attrs | preferred at UniFi Gateway |
ts-home2 → UniFi Gateway |
100.64.0.0/10 |
UniFi Gateway applies local-preference 50 (inbound) |
demoted at UniFi Gateway |
UniFi Gateway → ts-home1 |
192.168.0.0/16 |
default attrs | normal return path |
UniFi Gateway → ts-home2 |
192.168.0.0/16 |
UniFi Gateway applies metric 200 (MED, outbound) |
tagged as standby |
prefix-list lan permits 192.168.0.0/16 ge 16 only; prefix-list tailscale
permits 100.64.0.0/10 ge 10 only. Both lists are mirrored on the UniFi Gateway
and on the VMs so leaked specifics from either side are dropped. BGP timers
are 3 9 (keepalive 3 s, hold 9 s) on every neighbor, giving sub-10-second
failover.
| Element | Value |
|---|---|
| LAN aggregate (multiple VLANs) | 192.168.0.0/16 |
| WAN transit VLAN | 192.168.220.0/24 (VLAN 220, DHCP) |
| BGP transit VLAN | 192.168.221.0/24 (VLAN 221) |
| UniFi Gateway inside peer | 192.168.221.1 |
ts-home1 inside |
192.168.221.11 |
ts-home2 inside |
192.168.221.12 |
| Tailscale CGNAT | 100.64.0.0/10 |
| UniFi Gateway AS | 65000 |
| Debian VMs AS | 65001 |
- A UniFi Gateway running a UniFi OS version with FRR/BGP support (works on all current models).
- A virtualisation host (Proxmox is assumed here; anything that supports Debian 13 will work). Two hosts if you want true HA across hardware.
- A Tailscale tailnet and access to the admin console for route approval.
- Two VLANs reachable from the UniFi Gateway — one for WAN egress to the VMs and one for the BGP transit subnet.
- Installing the UniFi Gateway or provisioning VLANs on UniFi.
- Setting up a Tailscale tailnet.
- Installing Tailscale clients on remote devices.
- ACLs in Tailscale — see https://tailscale.com/kb/1337/acl-syntax.
The UniFi Gateway's
/etc/frr/frr.confcannot be edited directly on the CLI — the controller will overwrite it. The BGP config below must be pasted into the UniFi GUI (Settings → Routing → BGP) so the controller applies it through the supported path.
In the UniFi GUI, go to Settings → Routing → BGP and paste the following.
Replace 192.168.0.1 with the router-id you want to advertise; the value
below is a sensible default if your primary LAN sits in 192.168.0.0/24.
router bgp 65000
bgp router-id 192.168.0.1
bgp listen limit 10
neighbor 192.168.221.11 remote-as 65001
neighbor 192.168.221.11 timers 3 9
neighbor 192.168.221.12 remote-as 65001
neighbor 192.168.221.12 timers 3 9
!
address-family ipv4 unicast
redistribute connected
neighbor 192.168.221.11 soft-reconfiguration inbound
neighbor 192.168.221.11 prefix-list tailscale in
neighbor 192.168.221.11 prefix-list lan out
neighbor 192.168.221.12 soft-reconfiguration inbound
neighbor 192.168.221.12 prefix-list tailscale in
neighbor 192.168.221.12 prefix-list lan out
neighbor 192.168.221.12 route-map ts-home2-in in
neighbor 192.168.221.12 route-map ts-home2-out out
exit-address-family
exit
!
ip prefix-list lan seq 10 permit 192.168.0.0/16 ge 16
ip prefix-list tailscale seq 10 permit 100.64.0.0/10 ge 10
!
route-map ts-home2-in permit 10
set local-preference 50
!
route-map ts-home2-out permit 10
set metric 200
Create two VMs with identical specs, one per Proxmox host:
| Setting | Value |
|---|---|
| OS | Debian 13 Trixie (netinstall) |
| Machine | q35 |
| BIOS | UEFI (OVMF) |
| CPU | 1 vCPU |
| RAM | 1 GB |
| Disk | 8 GB |
net0 (ens18) |
VirtIO bridge → VLAN 220 (WAN) |
net1 (ens19) |
VirtIO bridge → VLAN 221 (inside) |
Install with minimal/standard system utilities only — no desktop.
Do everything below on both VMs unless noted.
# On ts-home1:
hostnamectl set-hostname ts-home1
# On ts-home2:
hostnamectl set-hostname ts-home2auto lo
iface lo inet loopback
auto ens18
iface ens18 inet dhcp
auto ens19
iface ens19 inet static
address 192.168.221.11
netmask 255.255.255.0
auto lo
iface lo inet loopback
auto ens18
iface ens18 inet dhcp
auto ens19
iface ens19 inet static
address 192.168.221.12
netmask 255.255.255.0
No default gateway on
ens19— WAN (ens18) DHCP provides the default route. Tailscale egresses via WAN. Adding a gateway to the inside interface will black-hole tailnet traffic.
systemctl restart networkingcat > /etc/sysctl.d/99-forwarding.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv4.conf.all.forwarding = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl --systemapt update && apt install -y frr frr-pythontoolsSet zebra and bgpd to yes; leave the rest as-is:
zebra=yes
bgpd=yes
frr version 9.1
frr defaults traditional
hostname ts-home1
service integrated-vtysh-config
!
ip route 100.64.0.0/10 Null0
!
router bgp 65001
bgp router-id 192.168.221.11
no bgp network import-check
neighbor 192.168.221.1 remote-as 65000
neighbor 192.168.221.1 update-source 192.168.221.11
neighbor 192.168.221.1 timers 3 9
!
address-family ipv4 unicast
redistribute static route-map tailscale
no neighbor 192.168.221.1 send-community
neighbor 192.168.221.1 soft-reconfiguration inbound
neighbor 192.168.221.1 prefix-list lan in
neighbor 192.168.221.1 prefix-list tailscale out
exit-address-family
exit
!
ip prefix-list lan seq 10 permit 192.168.0.0/16 ge 16
ip prefix-list tailscale seq 10 permit 100.64.0.0/10 ge 10
!
route-map tailscale permit 10
match ip address prefix-list tailscale
exit
!
frr version 9.1
frr defaults traditional
hostname ts-home2
service integrated-vtysh-config
!
ip route 100.64.0.0/10 Null0
!
router bgp 65001
bgp router-id 192.168.221.12
bgp default local-preference 200
no bgp network import-check
neighbor 192.168.221.1 remote-as 65000
neighbor 192.168.221.1 update-source 192.168.221.12
neighbor 192.168.221.1 timers 3 9
!
address-family ipv4 unicast
redistribute static route-map tailscale
no neighbor 192.168.221.1 send-community
neighbor 192.168.221.1 soft-reconfiguration inbound
neighbor 192.168.221.1 prefix-list lan in
neighbor 192.168.221.1 prefix-list tailscale out
neighbor 192.168.221.1 route-map gateway-in in
neighbor 192.168.221.1 route-map gateway-out out
exit-address-family
exit
!
ip prefix-list lan seq 10 permit 192.168.0.0/16 ge 16
ip prefix-list tailscale seq 10 permit 100.64.0.0/10 ge 10
!
route-map gateway-in permit 10
set local-preference 50
exit
!
route-map gateway-out permit 10
set metric 200
exit
!
route-map tailscale permit 10
match ip address prefix-list tailscale
exit
!
FRR runs as the frr user and will silently load an empty config if it
can't read frr.conf. Set ownership before starting:
chown frr:frr /etc/frr/frr.conf
chmod 640 /etc/frr/frr.conf
systemctl enable --now frrThe version on line 1 of frr.conf must match the installed version or FRR
will refuse to load the config:
vtysh --versionIf it reports something like 9.1.2, update line 1 to frr version 9.1.2
and restart:
systemctl restart frrcurl -fsSL https://tailscale.com/install.sh | shTailscale will warn about suboptimal UDP GRO on ens18 if this isn't set.
Install ethtool and apply the tunable persistently:
apt install -y ethtoolcat > /etc/systemd/system/tailscale-ethtool.service << 'EOF'
[Unit]
Description=Tailscale ethtool UDP GRO fix
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/sbin/ethtool -K ens18 rx-udp-gro-forwarding on rx-gro-list off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now tailscale-ethtoolReplace the --advertise-routes list with the LAN subnets you actually
want reachable from the tailnet:
tailscale up \
--advertise-routes=192.168.0.0/24,192.168.10.0/24,192.168.20.0/24,192.168.221.0/24 \
--advertise-exit-node \
--sshThe command prints an auth URL. Open it in a browser to authenticate the node. Repeat on the second VM — each node needs its own auth.
- Go to https://login.tailscale.com/admin/machines.
- For each of
ts-home1andts-home2: three-dot menu → Edit route settings → enable all advertised subnet routes and Use as exit node.
These VMs advertise routes; they should not absorb routes from other Tailscale nodes into the kernel routing table.
tailscale set --accept-routes=falseLAN traffic heading into the tailnet is masqueraded as the VM's Tailscale IP so peers without your subnet routes accepted (phones, laptops, exit-node users) can route return traffic. Peers that explicitly accept your subnet routes will work without NAT, but masquerading is worth keeping for robustness.
apt install -y iptables-persistent
iptables -t nat -A POSTROUTING -s 192.168.0.0/16 -o tailscale0 -j MASQUERADE
netfilter-persistent savetailscale0 is the daemon's interface on Linux. The rule masquerades to
the VM's current Tailscale IP automatically — no hardcoded 100.64.x.x.
Verify:
iptables -t nat -L POSTROUTING -v --line-numbersYou should see a MASQUERADE rule with tailscale0 as the output
interface and 192.168.0.0/16 as the source.
journalctl -u frr --no-pager | tail -30All four daemons should report state -> up:
watchfrr: zebra state -> up
watchfrr: mgmtd state -> up
watchfrr: bgpd state -> up
watchfrr: staticd state -> up
watchfrr: all daemons up, doing startup-complete notify
These NB_OP_CHANGE messages appear on every restart and are benign —
they're a race between zebra and mgmtd during startup with no
operational impact:
zebra: NB_OP_CHANGE: oper_walk_done: ERROR: Error sending notification message for path: /frr-interface:lib/interface[name="ens18"]/state
The line below confirms the UniFi Gateway pushed its full table and the session is up:
bgpd: rcvd End-of-RIB for IPv4 Unicast from 192.168.221.1 in vrf default
# Session — 192.168.221.1 should be in Established
vtysh -c "show bgp summary"
# Full BGP table — 100.64.0.0/10 must be present once Tailscale is up
vtysh -c "show ip bgp"
# What UniFi Gateway is sending us (your LAN prefixes)
vtysh -c "show ip bgp neighbors 192.168.221.1 received-routes"
# What we're advertising back (should be 100.64.0.0/10)
vtysh -c "show ip bgp neighbors 192.168.221.1 advertised-routes"# Blackhole route for the Tailscale aggregate — needed for redistribution
ip route show 100.64.0.0/10
# Full kernel routing table
ip route showtailscale status
tailscale status --peers=falseSSH to the gateway and confirm both neighbors are up and ts-home1 is
preferred (lower MED received, higher local-preference):
vtysh -c "show bgp summary"
vtysh -c "show ip bgp 100.64.0.0/10"To resolve tailnet hostnames from LAN clients, forward *.ts.net (or your
tailnet's MagicDNS domain) to Quad100. On a UniFi-managed network, add a
domain override to whatever resolver your LAN uses; if you happen to run
Unbound somewhere, the entry looks like:
- Domain:
ts.net(or your tailnet's MagicDNS domain) - Lookup Server IP:
100.100.100.100
References:
The script below checks the four things most likely to silently break subnet routing — wedged Tailscale daemon, missing NAT rule, missing blackhole route, downed BGP session — and restarts the relevant service. Before relying on it, look at logs when things fail so you understand the actual root cause:
journalctl -u tailscale --no-pager | tail -30
journalctl -u frr --no-pager | tail -30cat > /usr/local/bin/tailscale-watchdog.sh << 'EOF'
#!/bin/bash
LOG="logger -t tailscale-watchdog"
if ! tailscale status > /dev/null 2>&1; then
$LOG "Tailscale unresponsive, restarting"
systemctl restart tailscale
sleep 5
fi
if ! ip route show 100.64.0.0/10 | grep -q Null0; then
$LOG "Blackhole route missing, restarting FRR"
systemctl restart frr
fi
if ! iptables -t nat -L POSTROUTING | grep -q "tailscale0.*MASQUERADE\|MASQUERADE.*tailscale0"; then
$LOG "NAT rule missing, re-applying"
iptables -t nat -A POSTROUTING -s 192.168.0.0/16 -o tailscale0 -j MASQUERADE
fi
if ! vtysh -c "show bgp summary" | grep -q "192.168.221.1.*Established\|192.168.221.1.*[0-9]*:[0-9]*:[0-9]*"; then
$LOG "BGP session down, restarting FRR"
systemctl restart frr
fi
EOF
chmod +x /usr/local/bin/tailscale-watchdog.shecho "*/5 * * * * root /usr/local/bin/tailscale-watchdog.sh" > /etc/cron.d/tailscale-watchdogRuns every 5 minutes. Logs go to syslog under the tailscale-watchdog tag:
grep tailscale-watchdog /var/log/syslog- Wrong AS or peer IP: confirm the UniFi Gateway config has the VM in AS 65001
and the right neighbor IP; confirm the VM config has the UniFi Gateway as AS
65000 at
192.168.221.1. - Firewall on the inside VLAN: UniFi Gateway LAN-In may block TCP/179 between
192.168.221.0/24hosts. The default UniFi LAN policy allows intra-VLAN traffic; an explicit deny will kill BGP. update-sourcemismatch: theneighbor … update-sourceline on the VM must match the IP that's actually onens19. If you mis-typed it, the session stays inActiveand never moves toEstablished.- Config not loaded:
vtysh -c "show running-config"— if it's nearly empty, FRR couldn't readfrr.conf. Re-check ownership and mode.
- Prefix-list typo:
show ip prefix-listshould list bothlanandtailscalewith their permit entries. bgp network import-check: must be disabled (no bgp network import-check) for the static100.64.0.0/10to be advertised before Tailscale installs anything in the kernel table.route-map tailscalemissing:redistribute static route-map tailscalesilently advertises nothing if the named map doesn't exist.
- UniFi Gateway has the route?
vtysh -c "show ip route bgp"on the UniFi Gateway should list100.64.0.0/10via192.168.221.11. If it's via.12, your active VM ists-home2— check whyts-home1lost the best-path race. - NAT rule missing on the active VM: see NAT Configuration.
- Routes not approved in Tailscale admin console.
- Custom UniFi firewall policy blocking the traffic: UniFi's default
LAN-In policy lets clients route to BGP-learned destinations without an
explicit rule. If you've added a restrictive policy (e.g. a default-deny
on LAN-In or a VLAN-isolation rule), add an allow rule for traffic to
100.64.0.0/10.
- NAT not translating: packet-capture on
tailscale0(withtcpdump) and confirm outbound packets have a100.x.x.xsource, not a192.168.x.xone. If they don't, the masquerade rule isn't matching. - Asymmetric routing: if the active VM differs in each direction, packets get dropped by stateful filters. Confirm both UniFi Gateway and tailnet agree on which VM is active.
- BGP timers: confirm
timers 3 9on every neighbor — defaults (60 180) give you up to three minutes of black-holed traffic. - Tailscale primary subnet router stuck: when
ts-home1is down Tailscale should promotets-home2as the primary route advertiser; if it doesn't, the route advertisement onts-home1may be cached. Check the admin console.
Cutover is non-disruptive because the inside-VLAN IPs (.11, .12) are
reused — the UniFi Gateway peers with the same IPs, only the protocol underneath
changes from OSPF to BGP.
- Build
ts-home1. Bring up FRR and Tailscale. Approve routes in the admin console. - Build
ts-home2. Repeat. - Confirm BGP is
Establishedon both new VMs:vtysh -c "show bgp summary". - Confirm Tailscale subnet routes are active in the admin console.
- Test: ping a LAN host from a tailnet peer and vice versa.
- Shut down the old pfSense VMs (
pf1,pf2). - Verify both BGP sessions stay up after pfSense is gone.
- Decommission the pfSense VMs.
Because the inside IPs are being reused, the pfSense VMs must be fully powered off before the new VMs come up on the same IPs — otherwise you get a VLAN 221 IP conflict.
The UniFi Gateway BGP config replaces the OSPF config in
Settings → Routing. Once the new VMs are up, remove the OSPF area from
the UniFi controller; OSPF and BGP can coexist briefly during the cutover
but the OSPF adjacency to pfSense will fail anyway once pfSense is off.
