Avoiding Tailscale's DERP relays in my Homelab.
2026-08-14 · 8 min · Jasper Dijt
I'm a user of Tailscale in my Homelab. It has proven to be an excellent way of making the services I host for myself available anywhere. However, initially it was not possible to set up a direct connection to the services I was hosting on Kubernetes. This causes Tailscale to fall back to a so-called "DERP" relay [5] . Using a DERP relay lowers performance of the link, especially if it causes what could be local traffic to be routed over the internet. And it is just not nice. Part of the charm of Tailscale is the direct tunnels it can create.
In this blog post I will take you from the cause of the problem to how I ultimately ended up using BGP within my home network as part of the solution.
My home network & the homelab
My home network is slightly more complicated than the average home network. I have multiple VLANs to separate out the homelab from my personal devices. Traffic between these VLANs is routed and regulated through an OPNsense firewall.
Inside the Homelab cluster Cilium is used for the networking and the Tailscale Operator is used to manage the proxies to expose things to the tailnet.
architecture-beta
group homenet(tabler:topology-star-3)[Home network]
group hlnet(tabler:topology-star)[HomeLab Vlan] in homenet
group pnet(tabler:topology-star)[Private Vlan] in homenet
service fw(tabler:router)[OPNsense] in homenet
service hlk8s(simple-icons:kubernetes)[K8s] in hlnet
service desktop(tabler:device-desktop)[Desktop PC] in pnet
junction fwl in homenet
junction fwr in homenet
desktop{group}:T -- B:fwl
hlk8s{group}:T -- B:fwr
fwl:R -- L:fw
fwr:L -- R:fw
align row desktop hlk8s
align row fwl fw fwr
align column fwl desktop
align column fwr hlk8s
The most broken case
Devices in the private VLAN would always use DERP to connect to a k8s-hosted service. This means that for my desktop to connect to the wiki it would have to connect through Helsinki (closest DERP relay). Not great, to cross the Baltic to read the wiki that sits across the room.
flowchart LR
subgraph HomeNet
desktop@{ icon: "tabler:device-desktop", label: "PC" }
fw@{ icon: "tabler:router", label: "Firewall (Hop 1)" }
fw2@{ icon: "tabler:router", label: "Firewall (Hop 2)" }
homewiki@{ icon: "tabler:server", label: "Home Wiki" }
end
subgraph Internet
derp@{ icon: "tabler:server", label: "DERP Relay" }
end
desktop --> fw --> derp --> fw2 --> homewiki
The source of the issue: NAT Traversal
To connect two devices directly Tailscale needs to perform an operation called "NAT Traversal". They do a pretty good explanation of the intricacies of it themselves in [3] . What is important for this post is that not all NATs are implemented equally and there is the concept of the "Hard NAT". These are hard, sometimes impossible, to traverse for Tailscale.
When K8s does NAT, it is a Hard NAT [4] .
The solution: Get rid of all NAT
I considered just not using Tailscale for internal traffic, but ultimately rejected this because I liked the uniformity, same URL, same ease of access on all devices anywhere.
The only alternative I was left with was to get rid of the NAT within k8s.
I decided this was a nice opportunity to play with Cilium's BGP control plane and OPNsense's routing features. After all if I can directly route traffic to a Pod's IP, then there is no NAT, and then there is no problem.
The Tailscale team in their blog post on k8s connectivity [4] suggested a simpler solution. This is running Tailscale on the host NIC and would probably have worked. But in my opinion it is less elegant, and it certainly was not as much of a learning opportunity.
Configuring BGP
Preparations & Prerequisites
There were a few things I had to think of before I began. And I got a bit lucky because if some of these are wrong they are difficult to alter on a running k8s cluster.
- Pick AS numbers: In the BGP world each "peer" belongs to a so-called "Autonomous System" [1] . For the purposes of BGP the firewall/home network represent one AS, and the k8s internal world of Cilium another. I picked two numbers from the 16-bit private range.
- Check that the current PodCIDR does not overlap with any of the other networks in the home. Luckily this was the case.
- Decide if I wanted pod IPv6 addresses in the private space (
fd00::/8) or not. I opted to use a slice of the/56I got from my ISP for this. This will probably bite me in the ass if I ever switch ISP and get a new prefix. - Run Cilium in native routing mode. I was already doing this because I think it is the best option in my setup of real hardware all in the same VLAN/L2 domain.
Setup: OPNsense
I think the OPNsense setup was fairly straightforward. The inline help was all I needed.
Install the os-frr plugin, configure the BGP server & configure a "peer group" to allow Cilium to advertise routes to OPNsense.
The peer group in my case is configured such that it allows advertisements from any (Node) IP in the homelab subnet, for me this is fine.
Setup: Cilium
Similarly the setup for Cilium was also not very complicated. I only used the documentation provided at [2] . One unexpected gotcha was that I had to set up separate peer configs for IPv4 and IPv6. I do not fully understand why, but this was the only way I got my IPv6 routes to be advertised.
Also it is not possible to have a "default" (label-less) CiliumBGPAdvertisement. It needs to have a label and the CiliumBGPPeerConfig needs to select by it.
I ended up with something like:
apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata:
name: home-infra
spec:
nodeSelector:
matchLabels: {} #Apply to all nodes
bgpInstances:
- name: home-infra
localASN: 65000
peers:
- name: heimdall-v4
peerASN: 64512
peerAddress: <the firewall ipv4>
peerConfigRef:
name: heimdall-v4
- name: heimdall-v6
peerASN: 64512
peerAddress: <the firewall ipv6>
peerConfigRef:
name: heimdall-v6
---
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata:
name: heimdall-v4
spec:
gracefulRestart:
enabled: true
restartTimeSeconds: 15
families:
- afi: ipv4
safi: unicast
advertisements:
matchLabels:
advertise: bgp
---
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata:
name: heimdall-v6
spec:
gracefulRestart:
enabled: true
restartTimeSeconds: 15
families:
- afi: ipv6
safi: unicast
advertisements:
matchLabels:
advertise: bgp
---
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
name: bgp-advertisements
labels:
advertise: bgp
spec:
advertisements:
- advertisementType: "PodCIDR"
Finally on the Cilium level we have to disable NAT for the now routed ranges. In the helm chart that is the following settings.
routingMode: native
autoDirectNodeRoutes: true
ipv4NativeRoutingCIDR: 10.0.0.0/8
ipv6NativeRoutingCIDR: 2000::/3
It is important that the ipvxNativeRoutingCIDR settings include the PodCIDR. They don't have to exactly match.
I opted to natively route the entire 10.0.0.0/8 & the entire public IPv6 space 2000::/3.
Also good to highlight autoDirectNodeRoutes. This setting makes Cilium also add routes to the Linux kernel of the k8s nodes.
This ensures that pod-to-pod traffic gets routed directly node-to-node.
If this is not set, together with the BGP setup, all k8s traffic would go from the node, to the firewall, to the next node.
A few other gotchas and benefits
If you go down this route, OPNsense's automated NAT rule generation will not be enough, as this only generates rules for the networks configured on the local interfaces. You will have to add a manual rule for the IPv4 range, and potentially IPv6 range, your pods use. Also some firewall rules may need amending.
A positive side effect has been that it has become easier to debug traffic. On the firewall I can now see exactly from which Pod IP a blocked packet comes.
Another positive is that the same mechanism can be used to set up LoadBalancer IPs and advertise them to the firewall. This allows me to make LoadBalancer type services in k8s.
The end result
Direct connectivity to my Tailscale services in the Homelab cluster now works. No longer do I have to cross the sea to read my wiki.
$ tailscale status
100.118.8.64 home-infra-apiserver-0 tagged-devices linux active; direct 10.80.1.40:46948, tx 1675876 rx 32786324
100.86.142.33 home-infra-ingress-0 tagged-devices linux active; direct 10.80.1.159:36580, tx 394596 rx 554292
Some of you may be asking yourselves how I fixed the other NAT layer: OPNsense to the internet, as OPNsense has a hard NAT too [6] . This I solved by running a Tailscale Peer relay on the firewall. This is not in the suggestions of Tailscale's documentation on OPNsense. But it doesn't require me to simplify the NAT, and it doesn't require me to enable NAT-PMP, so that makes it infinitely superior in my opinion.
References
- Autonomous system (Internet) — accessed 2026-08-16
- Cilium BGP Control Plane — accessed 2026-08-16
- Tailscale blog: How NAT traversal works — accessed 2026-08-16
- Tailscale blog: Kubernetes, direct connections, and you — accessed 2026-08-16
- Tailscale docs: DERP servers — accessed 2026-08-16
- Tailscale docs: Using OPNsense with Tailscale — accessed 2026-08-16