Tailscale Exit Node on Linux: Policy-Based Routing

AI-generated

Running a Tailscale exit node on Linux is easy until you want more than just sending internet traffic through it. I use exit nodes from my Arch laptop and from VMs in my homelab, but I still want local LAN hosts, Docker networks and special routes to work as expected.

The important part is to treat Tailscale traffic as forwarded traffic. An exit node is not only a VPN client with a default route. It receives packets on tailscale0, routes them onwards and usually needs source NAT, especially for private LAN and Docker networks.

Basic Tailscale exit node setup

First, enable the exit node advertisement on the Linux machine:

# tailscale set --advertise-exit-node

The node must then be enabled as an exit node in the Tailscale admin console. Tailscale advertises both IPv4 and IPv6 default routes for an exit node.

On a client, I can select the node with:

$ tailscale set --exit-node=100.64.0.10

If I still want to access the local network where the client currently is, I enable local LAN access as well:

$ tailscale set --exit-node=100.64.0.10 --exit-node-allow-lan-access=true

This option matters on the client. Without it, a notebook using an exit node may try to reach its current local router through the exit node as well, which is usually not what you want.

Tailscale exit nodes are not a replacement for subnet routers. If you want to access a remote LAN from your tailnet, advertise that subnet separately with --advertise-routes.

I already use Tailscale together with SSH key authentication, but I still prefer keeping SSH protected by a hardware key where possible. My setup for this is described in Phishing-Resistant SSH on Linux with a YubiKey and FIDO2.

Enable Linux forwarding

The host needs IPv4 forwarding. IPv6 forwarding is needed too when you want to support IPv6 traffic through the exit node.

# cat >/etc/sysctl.d/99-tailscale-exit-node.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF

# sysctl --system

Check the active values:

$ sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1

On Gentoo with a custom kernel, make sure conntrack, nftables and packet forwarding support are enabled. On Arch Linux this normally comes with the stock kernel.

Forwarding with nftables

Tailscale can manage firewall rules by itself. That is fine for a simple host. For a router or a machine with Docker, libvirt or custom nftables rules, I prefer managing forwarding explicitly.

The following is a ruleset fragment, not a complete firewall. I add these rules to my existing forward chain instead of creating another chain with a conflicting policy drop.

Replace the interface names with your own ones:

define tailscale_if = "tailscale0"
define wan_if = "enp1s0"
define lan_if = "br0"
define docker_if = "docker0"

I allow established connections first, then traffic from Tailscale to the internet, LAN and Docker bridge:

ct state established,related accept

iifname $tailscale_if oifname $wan_if accept
iifname $tailscale_if oifname $lan_if accept
iifname $tailscale_if oifname $docker_if accept

For an exit node, the first rule is the important one. It permits packets arriving from Tailscale to leave through the normal internet interface.

The LAN and Docker rules are optional, but I normally need them. Without them, a device connected through my exit node may have internet access but cannot reach a local service or a container.

Do not blindly replace Docker's firewall rules with a new nftables forward chain using policy drop. Docker still installs firewall rules through its iptables compatibility layer on many distributions. Put your rules into the existing forwarding design, or test container networking carefully afterwards.

Docker networks are especially easy to miss. Default Docker uses docker0, but compose projects often create additional br-<id> bridges. I either add these explicitly or use a dedicated Docker network range in my firewall rules.

Source NAT for LAN and containers

Internet traffic needs masquerading so that replies return through the exit node. The same is true for most local LANs, because normal LAN devices do not know a route back to Tailscale's 100.64.0.0/10 range.

In my nftables NAT postrouting chain I use:

ip saddr 100.64.0.0/10 oifname $wan_if masquerade
ip saddr 100.64.0.0/10 oifname $lan_if masquerade
ip saddr 100.64.0.0/10 oifname $docker_if masquerade

The first line is needed for normal exit-node traffic. The other two make local services work without adding static routes on every LAN host or inside Docker.

If I control the LAN router, I can instead add a route for 100.64.0.0/10 via the exit node. Then LAN systems see the real Tailscale client address. For a small homelab, masquerading is usually less work.

This is similar to networking around KVM guests. In my GPU pass-through machines I also prefer being clear about which bridge or network is routing traffic instead of relying on accidental defaults. I wrote about the VM side in Linux KVM GPU pass-through with identical graphic cards.

Policy routing for selected traffic

A normal exit node can use the main routing table. Policy-based routing becomes useful when the host has multiple uplinks, another VPN, a management network or routes that must never use the WAN default route.

I mark packets arriving on tailscale0 early, before the route lookup:

table inet mangle {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;

        iifname "tailscale0" meta mark set 0x100
    }
}

Then I create a routing table for marked exit-node traffic:

# ip route add default via 192.168.1.1 dev enp1s0 table 100
# ip route add 192.168.1.0/24 dev br0 table 100
# ip route add 172.17.0.0/16 dev docker0 table 100

# ip rule add priority 100 fwmark 0x100/0xff lookup 100

The route table needs every destination that should remain local. This is the common pitfall. If table 100 only contains a default route, traffic for the Docker subnet or LAN may be sent to the upstream router instead.

Check what Linux would do for a marked packet:

$ ip route get 172.17.0.2 mark 0x100
172.17.0.2 dev docker0 table 100 src 172.17.0.1 mark 0x100

For a Docker host I usually add every relevant bridge subnet to table 100. For Podman, I often avoid this complexity by keeping services rootless and exposing only selected ports. My Rootless Podman with Quadlet: systemd Services and Automatic Updates setup is a good fit for that.

Persist ip rule and ip route commands with your distribution's network tooling. On a systemd-networkd host, I use .network route and routing policy rule sections. On a simple homelab node, a small systemd oneshot service is also fine.

After changing firewall or routing rules, test from a Tailscale client with internet access, a LAN service and a container service. If internet works but private targets do not, it is almost always a missing forward rule, NAT rule or route in the policy routing table.

If you know a cleaner way to handle multiple Docker bridges with nftables, let me know.