Homelab: FreeBSD pf Router
FreeBSD 15 on a CWWK N100, behind an XGS-PON SFP+ module, with the AT&T gateway out of the path
What This Is
At the edge of my homelab is a small FreeBSD 15 box, hostname homefw,
that handles every packet coming into and out of my network. It's the WAN router,
the firewall, the DHCP and DNS server, the VPN endpoint, and the boundary that
everything else sits behind. The AT&T fiber gateway is gone; an
X-ONU-SFPP XGS-PON SFP+ module takes the fiber directly and slots
into one of the box's 10G SFP+ ports, so the FreeBSD host gets the public IP itself.
I built it instead of buying a turnkey router for the same reason I run NixOS on
servers: I want the configuration to be obvious, version-controlled, and learnable.
FreeBSD's pf, rc.conf, and tightly integrated networking
stack give me exactly that.
Why Not OPNsense or pfSense?
OPNsense and pfSense are both excellent, and both are FreeBSD-based. But they ship a heavyweight web UI on top of FreeBSD, and the moment you click into "Advanced", you discover the UI doesn't expose what you actually want to configure. You SSH in, edit a hidden file, and now your config is split between the GUI and a shadow config you have to remember.
Plain FreeBSD avoids that split. Every line of configuration is in
/etc/rc.conf, /etc/pf.conf, /usr/local/etc/unbound/,
readable, greppable, and trivial to put in git. The router is small enough to fit
in my head, which is the entire point.
Hardware
- Chassis: CWWK Intel Alder Lake N100 mini PC (4C/4T, up to 3.4 GHz)
- 10G uplinks: Dual Intel SFP+ 10 GbE (the FreeBSD
ix(4)driver). One hosts the XGS-PON ONT for WAN, the other carries the LAN trunk. - 2.5G ports: 4x Intel I226-V 2.5 GbE (the
igc(4)driver). Spare today, useful for an OOB management LAN or a future split. - Memory: 8 GB DDR5
- Storage: 128 GB NVMe with ZFS root and boot environments
- WAN ONT: X-ONU-SFPP XGS-PON SFP+ module, pre-flashed with the 8311 community firmware and the Azores bootloader. SC/APC connector takes the AT&T fiber directly. Active USB-C cooler attached because these modules run hot.
- Switch downstream: Sodola 12-port 10G managed switch (8x SFP+ / 4x 10GBase-T, 1U), carrying the LAN trunk and the VLAN 20 tag out to the UniFi APs
Intel NICs matter. Realtek and other budget NICs technically work, but they regularly cost more in debugging time than the BOM saved.
Network Topology
┌────────────────────────────────┐
│ AT&T XGS-PON Fiber │
│ (SC/APC, no ISP gateway) │
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ X-ONU-SFPP (XGS-PON SFP+ ONT) │
│ 8311 firmware · Azores boot │
└───────────────┬────────────────┘
│ ix0 (WAN, 10G SFP+)
┌───────────────▼────────────────┐
│ FreeBSD 15 Router (homefw) │
│ pf · unbound · ISC dhcpd │
└───────────────┬────────────────┘
│ ix1 (LAN trunk, 10G SFP+)
┌───────────────▼────────────────┐
│ Sodola Managed Switch │
└──┬─────────────────────────┬───┘
│ untagged │ VLAN 20 tagged
┌──▼──────────┐ ┌────▼─────────┐
│ UniFi APs │ │ UniFi APs │
│ SSID: │ │ SSID: │
│ UniWorld │ │ UniWork │
│ 10.0.0.0/24 │ │ 10.20.0.0/24 │
│ (main LAN) │ │ (ix1.20) │
└──┬──────────┘ └──────────────┘
│
┌──▼──────────────────┐
│ Wired LAN │
│ Workstations │
│ Servers / homelab │
└─────────────────────┘
One trunk down to the Sodola switch, two SSIDs out at the UniFi APs.
UniWorld is the main LAN, untagged on
ix1. UniWork is VLAN 20, terminated on
ix1.20 with its own subnet, so devices on that SSID never share a
broadcast domain with the rest of the house and have to traverse pf to talk to
anything else.
Software Stack
- FreeBSD 15.0-RELEASE (pkgbase): base OS, ZFS root, GENERIC kernel
- pf: stateful firewall, NAT, anti-spoofing
- unbound: local recursive DNS, DNSSEC validation, blocklists
- ISC dhcpd: leases on the main LAN and VLAN 20
- ntpd: local NTP for the network
- WireGuard: road-warrior VPN back into the LAN
- iocage / bastille: jails for service isolation
Everything is in base or in pkg. Running pkgbase means base updates
flow through the same package manager as everything else, which keeps the
boot-environment story clean.
The Configuration, in Spirit
# /etc/rc.conf (excerpt)
hostname="homefw"
zfs_enable="YES"
# Forwarding for v4 and v6
gateway_enable="YES"
ipv6_gateway_enable="YES"
# WAN: SFP+ port hosting the X-ONU-SFPP XGS-PON ONT
ifconfig_ix0="DHCP"
ifconfig_ix0_ipv6="inet6 accept_rtadv"
background_dhclient_ix0="YES" # XGS-PON DHCP can be slow at boot
# LAN trunk and VLAN 20 (UniWork)
ifconfig_ix1="inet 10.0.0.1 netmask 255.255.255.0"
vlans_ix1="20"
ifconfig_ix1_20="inet 10.20.0.1/24"
# Services
pf_enable="YES"
pflog_enable="YES"
unbound_enable="YES"
dhcpd_enable="YES"
dhcpd_ifaces="ix1 ix1.20"
sshd_enable="YES"
Two lines worth flagging. background_dhclient_ix0="YES" is the line
that fixed a class of boot-time bugs: the XGS-PON ONT sometimes takes a few
seconds to settle and hand out a DHCP lease, and without backgrounding the boot
would hang waiting for it, dragging ntpd, unbound, and even sshd down with it.
The VLAN pair (vlans_ix1 plus ifconfig_ix1_20) is what
creates the ix1.20 interface; if that interface doesn't exist before
pf loads, anything in pf that references it silently breaks.
Most of the rest is in /etc/pf.conf, see the
pf rules deep dive for the full
ruleset and the reasoning behind it.
Operational Habits
- git-tracked config.
/etc,/usr/local/etc, and the jail manifests live in a private git repo. Every change is a commit with a message. - ZFS boot environments. Before any meaningful change I clone
the boot environment with
bectl create. If something breaks, I reboot to the previous BE in seconds. - pfctl -nf before pfctl -f. Always validate the ruleset before loading it. Never edit pf.conf in place over SSH without a safety net.
- Console access. Serial console wired up. The day pf locks me out is the day I'm grateful for it.
- ZFS snapshots + offsite send. Hourly snapshots, daily replication to a remote ZFS host. Belt and suspenders.
Articles in This Series
- Bypassing the AT&T Fiber Gateway with an XGS-PON SFP+ Module: how the AT&T BGW320-500 came out of the path and the X-ONU-SFPP went into
ix0 - Building a FreeBSD pf Router behind XGS-PON: hardware, install, and first-boot configuration on the host this WAN attaches to
- pf.conf: Writing Rules That Survive a Power Outage: rule design, NAT, anti-spoofing, and a real config
- FreeBSD Jails for Network Services: using VNET jails to isolate DNS, monitoring, and VPN
- IPv6 for Home Networks: A FreeBSD Walkthrough: DHCPv6-PD, rtadvd, and v6-aware pf rules
- IPv6 Prefix Delegation: A Troubleshooting Cookbook: debugging dhcp6c, RAs, RDNSS, and PMTU
- WireGuard on FreeBSD: A 30-Minute Setup: server, clients, pf, and DNS through the tunnel
- ZFS Send/Recv: Replicating Your Homelab: snapshots, incremental streams, and a real pull-based cron script
- FreeBSD vs Linux: An SRE's Take: when to reach for each, and why I run both
Related Reading
- FreeBSD Handbook: the canonical reference, written like an actual book
- pf.conf(5): the manual page that does most of the teaching
- jail(8): the original lightweight container, still going strong
Have questions about the build, or notice something I'd benefit from changing? Drop me a line.