Tailscale Behind OPNsense: Why Everything Goes Through DERP
Tailscale starts every connection on a DERP relay and then tries to upgrade it to a direct WireGuard path. Behind OPNsense that upgrade kept failing in my lab, and the reason was the way pf picks the outside port for UDP. This is how the hole punching works, and the three NAT modes that make or break it.
I use Tailscale for remote access to my lab. Every lab machine runs the Tailscale client, and every connection to it is tied to an identity, a user or a tagged machine, rather than to the fact that it came from the right network. That is the whole idea behind zero trust network access (ZTNA): being on the right network gives you nothing, and an explicit grant gives you exactly what it says. And because Tailscale needs no inbound port forwarding, OPNsense, the firewall in front of the lab, does not need a single open port for any of it.
The other part is microsegmentation. The tailnet policy file decides who can reach which service on which machine, down to the port. A rule can say that my user gets SSH on everything tagged lab, while a monitoring host gets the metrics port and nothing else. All of it lives in one file, so who can reach what is easy to read and easy to change. This all worked for me. It was just slower than it should have been.
What is Tailscale anyway?
Tailscale is a mesh VPN built on WireGuard. Every device gets its own WireGuard key and an address from the 100.64.0.0/10 range, and talks to every peer it is allowed to over a tunnel of its own. There is no central VPN server for all the traffic to squeeze through. What is central is the coordination server: it handles login through your identity provider, distributes public keys and the policy, and tells each node where its peers can be reached. Your traffic does not go through it.
The catch with any peer-to-peer design is that most devices sit behind NAT and cannot simply accept a packet from the internet. Tailscale deals with that through NAT traversal and, when that fails, falls back to DERP (Designated Encrypted Relay for Packets), a network of relay servers that forward the already encrypted WireGuard packets between peers. There is a lot more to Tailscale than this, and I might write a longer post about it some other time, but this much is enough to follow the rest.
The problem: everything goes through DERP
Tailscale starts every connection on DERP and tries to upgrade it to a direct path in the background. Normally that happens quickly and you never notice. My connections to the lab machines never upgraded. tailscale ping never got past via DERP, and tailscale status was no help at all: it showed a - next to every one of them, which says nothing about the path.
Nothing was broken, strictly speaking. DERP cannot read the traffic it relays, so the connections were just as private and everything worked. But every packet took a detour through a relay server somewhere, which costs latency and throughput, and DERP is meant to be a fallback, not the way lab traffic travels every day.
The culprit turned out to be the NAT on OPNsense, or more precisely the way pf, the packet filter OPNsense inherits from FreeBSD, picks the outside port for a UDP flow. To see why that matters, we need to look at how Tailscale builds a direct path in the first place.
How Tailscale builds a direct path
tailscaled sends traffic through DERP from the very first packet and looks for something better at the same time. As soon as one of its probes makes a round trip, the connection moves over. Tailscale’s own article on how NAT traversal works covers this in far more detail than I will here. The short version is three steps:
- Learn the public address.
tailscaledsends STUN requests to the DERP servers from its WireGuard socket, UDP port 41641 by default. Each server replies with the publicip:portit saw. It has to be that exact socket, because the NAT keeps a separate mapping for every socket behind it. - Swap endpoints. The coordination server hands each node the other’s candidates: LAN addresses, public addresses and the ports discovered through STUN. A “call me maybe” message from disco, Tailscale’s own discovery protocol, goes over DERP and tells the peer to start probing right away.
- Punch the hole. Both nodes send disco pings to every candidate at about the same time. A stateful firewall lets an inbound UDP packet through once it has seen an outbound one to that
ip:port, so each node’s own ping opens its NAT for the other’s. The first ping that gets a pong back becomes the WireGuard path.
:62001) is also the port host B sees, so the probes in step 3 meet. Dashed lines are discovery traffic and the solid line is the direct WireGuard path.Notice the assumption hiding in step one. The port STUN reports is the port we tell the peer to use. If the NAT picks a different outside port for every destination, the port the DERP server saw and the port the peer sees are no longer the same, and the whole scheme falls apart.
Which is exactly what pf does by default.
pf and network address translation
Every tailscaled behind the firewall uses UDP port 41641 unless told otherwise. What the rest of the internet sees depends on the outbound NAT on OPNsense, and pf can do it in three different ways:
| pf mode | outside port | two hosts on :41641 |
|---|---|---|
| default | random, per destination | both relay |
| static port | same as the inside port | first direct, second relays |
| endpoint-independent | stable, per inside ip:port | both direct |
Only the last row is what Tailscale actually needs.
Default pf NAT: a new port for every destination
By default, pf rewrites the source port of every outbound connection, and it does so per state. Each new destination gets a fresh random port between 50001 and 65535. Tailscale’s OPNsense guide calls this endpoint-dependent mapping, or “hard” NAT.
So STUN sees one port and host B sees another. B probes the port A advertised, which only accepts packets from the DERP server. A’s probe arrives from a port B has never heard of. Neither side gets a pong, and the connection stays on DERP.
It can still go direct when the peer needs no hole punching at all, for example a VPS with a public IP and UDP open. A’s probe gets in, and the peer simply replies to whatever port it came from. But the peers I actually care about, a laptop or a phone on mobile data, sit behind NAT of their own, and there it breaks.
Static port: predictable, until the second host
The fix Tailscale’s OPNsense guide recommends is a hybrid outbound NAT rule for UDP with Static Port ticked. pf then stops rewriting the source port, and the outside port becomes predictable. The trouble is that it is 41641 for every host. The first host to claim it goes direct. The others collide and relay.
With a single Tailscale node behind the firewall this is all you need. With a lab full of them, the hosts have to stop sharing a port:
- Give each node its own port with
tailscaled --port=41642, or thePORT=line in/etc/default/tailscaled. - Set
"randomizeClientPort": truein the tailnet policy file, so every node picks a random port of its own.
Endpoint-independent mapping: one stable port per host
This is the mode Tailscale actually wants. EIM keeps one outside port for each inside ip:port, no matter where the packets go. Two hosts on :41641 get two different outside ports, and each one is the port that STUN and every peer see. That is the situation in the first diagram, and it needs no per-host configuration at all.
pf does this through the endpoint-independent option for UDP NAT rules. It landed in FreeBSD building on patches by Damjan Jovanovic and Naman Sood, with Tailscale and the FreeBSD Foundation sponsoring the work.
Checking what your NAT does
All three checks run on a host behind OPNsense:
tailscale netcheckreportsMappingVariesByDestIP.falsemeans the NAT keeps one port for all destinations.truemeans the port changes per destination, as with default pf.tailscale ping <peer>answersvia DERP(...)at first. Once a direct path works, the pongs show anip:portinstead.tailscale statusshowsdirect <ip:port>orrelay "<region>", but only for a peer with an active connection. An online peer that has not exchanged any traffic gets a plain-, and one that has gone quiet getsidle, so open a connection to the peer first.
This is what a working path looks like: two pongs through DERP, then the direct path takes over.
$ tailscale ping lab-vm-02
pong from lab-vm-02 (100.101.7.12) via DERP(fra) in 31ms
pong from lab-vm-02 (100.101.7.12) via DERP(fra) in 28ms
pong from lab-vm-02 (100.101.7.12) via 198.51.100.7:40000 in 3ms
$ tailscale netcheck | grep Mapping
* MappingVariesByDestIP: false
Things that will still bite you
- OPNsense behind another router. Only the last NAT before the internet matters to the peer. If OPNsense runs as a VM behind your ISP’s router, that outer router has to keep the port stable too, and nothing you change on OPNsense will fix that.
- NAT-PMP and UPnP. Tailscale’s OPNsense guide offers NAT-PMP as the other way to get direct paths. It runs into the same collision as static port: every node asks the firewall for 41641, and as one reply on the Tailscale forum puts it, “only one of them wins at any given time”.
The fix I used
In the end, the fix was the last row of the table. I enabled endpoint-independent mapping on OPNsense, and that was it: no static port, no per-host ports and nothing in the tailnet policy file that is only there because of the firewall. Every lab machine now goes direct, and DERP is back to being what it was meant to be, a fallback I never notice.
At least until the next NAT in the path decides otherwise.