Use FreeBSD

In the age of AI, there are still craftspeople who insist on doing things by hand. Using FreeBSD in 2026 feels a bit like that. I first ran into the BSD family when I was just getting started with Linux. After all, they all trace back to Unix. Over the years, I kept glancing back at it now and then, the way you look at the toy in the most eye-catching shop window on the street when you were a kid: always thinking about it, but never allowed to bring it home. Each time I would stop, download an image, look at the commands, look at the interface, and then stop there. The reason was simple: I could never answer one practical enough question. What problem would it solve for me? If I could already get the job done with NixOS, or some other Linux distribution, why would I spend time adapting to a whole new system? For engineers, interest matters, of course, but what actually justifies the migration cost is usually not interest. It is a problem. This year, I finally ran into one. It was not dramatic, but it was stubborn enough to make me reconsider: maybe this was a job for BSD.

A few years ago, I mentioned in another post that I had built a home router with NixOS. It handled VLAN management, IP allocation, firewall policy, traffic shaping, and a long list of other tasks. As the core router, it worked hard for three years and was stable, reliable, and almost entirely trouble-free. But I actually wanted more from it. I wanted it to be not just a router, but a reliable transparent proxy gateway as well. In other words, besides handling basic routing, it should also split traffic from every device in the house according to geo or domain-list rules and send it through different outbound tunnels, instead of relying on each client to configure its own proxy.

On Linux, the main thing I tried was TPROXY for packet marking and redirection. I spent a lot of time trying to understand the various iptables paths, and I tweaked nftables rules over and over again. But honestly, I never got TPROXY to work stably the way I wanted. Sometimes DNS hijacking broke. Sometimes traffic from certain applications behaved incorrectly. The most frustrating part was that it did not fail completely. It was always almost there. And that is somehow worse than a clean failure, because you always think another two hours will finally fix it, and yet after one attempt after another, the problem, or some new problem, is still there. After endlessly tuning nftables, I became more and more irritated and eventually gave up. I settled for something much less ambitious: opening an HTTP proxy port and letting clients decide for themselves whether to use it.

The turning point came when I got hold of a Mac mini. It was a 2014 model, already old by any reasonable standard, and the original 2.5-inch spinning disk was painfully slow. I replaced it with a 512 GB SSD and upgraded the memory to 16 GB. After all that tinkering, it could just barely run macOS, but it was still far from suitable for daily work. So I decided to give it the role that NixOS had never quite managed to fill: traffic gateway.

I had heard about Surge a long time ago, and I still think it is one of the best products of its kind on macOS. Buy a license, click a few times, and it starts working. Fake DNS, packet marking, forward/input chains, all the details that had given me endless headaches on Linux were handled neatly here. It just quietly took over the traffic. The out-of-the-box experience was so good that if my network requirements had not become more complicated later, Surge probably would have been the end of the story.

My network has several VLANs: a main VLAN for servers and homelab gear, a go VLAN for everyday laptops and phones, and an iot VLAN for smart devices. There are clear access boundaries between them, and some devices even live in more than one segment. In an environment like this, a proxy gateway is not an isolated appliance. It has to obey the logic of the whole network design. This is exactly where Surge started to rub against my existing architecture. It took over most of the traffic in the go VLAN, and even pulled in responsibilities like DHCP. The default behavior was certainly convenient, but once I wanted it to cooperate more precisely with the rest of the network, for example handling cross-VLAN access while preserving the original separation of responsibilities, things stopped feeling intuitive. It was not that Surge could not work. It was that it behaved more like a well-packaged standalone product than a network component I could fully control.

So what I needed was not merely a good proxy application. I needed a transparent gateway whose behavior was predictable, whose responsibilities were clear, and which could fit into my existing network design. Linux did not solve that problem in a way that satisfied me, and commercial software did not either. So why not let BSD take a shot at it?

I did not spend much time agonizing over which BSD to pick. I quickly downloaded FreeBSD, which seemed to have the more active community, and installed it on the old Mac mini. Conceptually, using it as a traffic gateway is not all that different from Linux: the firewall catches and redirects traffic first, then passes it to a routing service, which sends requests to different exits based on domain or IP policy. The real difference is the firewall and network configuration model. FreeBSD uses pf, and this is where the whole thing finally started to feel natural again. The rules in pf are simple, clear, and intuitive. More importantly, what you write is more or less what the system does. In other words, a pf configuration can express my requirements fairly directly, instead of forcing me to stare at a pile of complex rules and guess which link in the chain had gone wrong. That familiar engineering instinct finally came back: the configuration file no longer felt like a ritual of trial and error, but more like a specification I could actually trust.

In this setup, there are really only three core rules: NAT, DNS redirect, and TCP redirect. This is not a simplified example. These are, in fact, all of my pf rules:

# PF firewall rules for transparent proxy gateway
# Transparent proxy for VPN VLAN (10.10.40.0/24)
#
# Traffic flow:
#   DNS:  VPN VLAN clients -> PF rdr :53 -> AdGuard Home (filtering + rewrites)
#         -> Xray DNS :15353 (split domains) -> internet
#   TCP:  VPN VLAN clients -> PF rdr -> Xray tproxy (127.0.0.1)
#         -> Xray outbound via ext_if (default route) -> internet

# Interfaces
ext_if = "{{ vpn_interface }}"
int_if = "{{ vpn_interface }}"

# Xray ports
xray_port = "{{ xray_tproxy_port }}"
dns_port = "{{ xray_dns_port }}"

# AdGuard Home DNS port
adguard_dns_port = "{{ adguard_dns_port }}"

# --- NAT ---
# Masquerade outbound traffic from VPN VLAN (for direct connections via Xray)
# Exclude same-subnet destinations so DNS replies to local clients keep source port 53 on single-NIC hosts.
nat on $ext_if from {{ vpn_network }}/24 to !{{ vpn_network }}/24 -> ($ext_if)

# --- Redirects ---
# DNS: redirect all DNS from VPN VLAN clients to AdGuard Home (ad filtering + local records).
rdr on $int_if proto { udp, tcp } from {{ vpn_network }}/24 to any port 53 -> 127.0.0.1 port $adguard_dns_port

# TCP: redirect all TCP from VPN VLAN clients to Xray transparent proxy
# Skip traffic destined to the gateway itself (avoid redirect of local management)
rdr on $int_if proto tcp from {{ vpn_network }}/24 to !{{ vpn_ip }} -> 127.0.0.1 port $xray_port

# Pass everything else (traffic filtering is handled by Xray routing rules)
pass all

Things became quiet almost immediately. There were no extra packet marks, no need to reason back and forth across different chains, and no separate guessing game for whether something had once again gone wrong in the DNS path or the TCP path. It just started working, steadily.

For infrastructure, quiet is high praise. It means you can finally move your attention from troubleshooting back to building. On top of this box I went on to deploy ntopng for traffic monitoring and built a new dashboard to look at metrics. With AI helping along the way, all of that supporting work moved smoothly. Not because the work itself had become simpler, but because the underlying system had finally stopped generating noise.

Because every home network is different, I am not going to dump every last detail of the configuration here. For systems like this, a working setup usually depends heavily on local conditions, and copying someone else’s homework is of limited value. But if you are like me and have always wanted to try BSD without quite having a good enough reason, then here is my answer: plain confidence.