Building a FreeBSD pf Router
Published on
Why Build It Yourself?
Consumer routers run a Linux kernel from 2017, a vendor-modified userspace, and a web UI that lies about what's actually configured. OPNsense and pfSense are excellent — they're both FreeBSD-based, in fact — but their abstraction is also their burden: the GUI eventually doesn't expose the knob you need, and you end up editing a config file that the GUI may overwrite tomorrow.
Running plain FreeBSD as your router gives up the GUI and gains everything
underneath: a stable kernel, the OpenBSD-derived pf firewall, jails
for service isolation, ZFS for storage and rollback, and a documentation tradition
that takes itself seriously. This post walks through how I build one.
Hardware Pick
I'm boring on purpose:
- Fanless 4-port mini PC, Intel N5105 or N100 class. Search for "Protectli", "Topton", or "Qotom" — many vendors sell variants of the same reference design.
- 4× Intel i225/i226 2.5 GbE NICs. These use the FreeBSD
igc(4)driver, which is in base. - 16 GB DDR4 SODIMM. 8 GB is plenty for a router, 16 GB leaves headroom for jails and ZFS ARC.
- 256 GB NVMe. Anything reputable. Routers don't write much.
Avoid Realtek NICs unless you enjoy writing forum posts. Intel chips are boring and that's the highest praise you can give a router NIC.
Install Media
Grab the latest FreeBSD 14.x memstick image and write it to a USB drive:
# From a Linux/macOS box
curl -OL https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/14.2/FreeBSD-14.2-RELEASE-amd64-memstick.img
sudo dd if=FreeBSD-14.2-RELEASE-amd64-memstick.img of=/dev/sdX bs=1M status=progress conv=fsync
Plug it in, boot the mini PC, and at the loader prompt drop into a serial console if your hardware supports it (most fanless boxes do, via a DB9 or RJ45 console port). Working over serial means you can recover from your own mistakes later.
Install — ZFS Root, Auto, with One Tweak
bsdinstall is genuinely good. Walk through it normally and pick:
- Auto (ZFS) for the partition layout
- stripe with one disk (or mirror if you have two NVMe slots)
- Enable
sshdat the services prompt; everything else can wait - Add a non-root admin user in the
wheelgroup
Reboot, log in over SSH from a workstation cabled to one of the LAN-side NICs, and don't touch a thing on the WAN side until pf is loaded.
Naming the Wires
Before any configuration: figure out which physical port maps to which kernel
interface name. ifconfig shows you the names; the labels on the case
tell you which is which. Plug a workstation into one port at a time and watch
which interface comes up.
$ ifconfig -l
igc0 igc1 igc2 igc3 lo0
$ ifconfig igc0 | grep status
status: active
I conventionally use:
igc0— WANigc1— trusted LANigc2— servers / homelabigc3— IoT / quarantine
/etc/rc.conf — the One File Most Routers Need
hostname="gw.lan"
zfs_enable="YES"
# Forwarding both IP versions
gateway_enable="YES"
ipv6_gateway_enable="YES"
# Interfaces
ifconfig_igc0="DHCP"
ifconfig_igc1="inet 10.10.10.1/24"
ifconfig_igc2="inet 10.10.20.1/24"
ifconfig_igc3="inet 10.10.30.1/24"
# Firewall
pf_enable="YES"
pflog_enable="YES"
# DNS resolver (FreeBSD's built-in unbound)
local_unbound_enable="YES"
# DHCP server (from pkg)
dhcpd_enable="YES"
dhcpd_ifaces="igc1 igc2 igc3"
# Time
ntpd_enable="YES"
ntpd_sync_on_start="YES"
# SSH (limit it later in pf.conf)
sshd_enable="YES"
Apply piecewise:
$ service netif restart
$ sysctl net.inet.ip.forwarding=1
$ sysctl net.inet6.ip6.forwarding=1
Bootstrap pf with a Safety Net
Don't start pf with an empty ruleset and rely on default-pass. Don't start it with a deny-all and lock yourself out either. Start with the smallest ruleset that keeps SSH and the LAN working, then iterate.
# /etc/pf.conf — bootstrap, replace with the real ruleset later
ext_if = "igc0"
int_if = "{ igc1 igc2 igc3 }"
set skip on lo0
scrub in all
# NAT outbound from any internal LAN
nat on $ext_if from { 10.10.10.0/24 10.10.20.0/24 10.10.30.0/24 } to any -> ($ext_if)
# Default deny inbound on the WAN
block in log on $ext_if all
# Pass everything from internal networks (we'll tighten this later)
pass in on $int_if from any to any keep state
pass out all keep state
Validate before you load it:
$ pfctl -nf /etc/pf.conf
$ service pf start
$ pfctl -s rules
See the pf.conf design article for the production ruleset I actually use.
unbound — Local Recursive DNS
FreeBSD ships unbound in base under the name local_unbound.
Set it up to listen on the LANs:
# /var/unbound/unbound.conf (managed by local-unbound-setup)
server:
interface: 10.10.10.1
interface: 10.10.20.1
interface: 10.10.30.1
access-control: 127.0.0.0/8 allow
access-control: 10.10.0.0/16 allow
access-control: 0.0.0.0/0 refuse
hide-identity: yes
hide-version: yes
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
prefetch: yes
Restart:
$ service local_unbound restart
$ drill -u google.com @10.10.10.1 # check DNSSEC validation
dhcpd — Leases for the LANs
# /usr/local/etc/dhcpd.conf (excerpt)
default-lease-time 3600;
max-lease-time 86400;
authoritative;
subnet 10.10.10.0 netmask 255.255.255.0 {
range 10.10.10.100 10.10.10.200;
option routers 10.10.10.1;
option domain-name-servers 10.10.10.1;
option domain-name "lan";
}
subnet 10.10.20.0 netmask 255.255.255.0 {
range 10.10.20.100 10.10.20.200;
option routers 10.10.20.1;
option domain-name-servers 10.10.20.1;
}
subnet 10.10.30.0 netmask 255.255.255.0 {
range 10.10.30.100 10.10.30.200;
option routers 10.10.30.1;
option domain-name-servers 10.10.30.1;
}
SSH — Belongs on the LAN, Not the WAN
Three things every router SSH config needs: key-only login, no root login, and a pf rule that limits SSH to the trusted LAN.
# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
AllowUsers admin
ListenAddress 10.10.10.1
ZFS Boot Environments — Cheap Insurance
Before you change anything important, snapshot the boot environment so a single reboot reverts you:
$ bectl create pre-pf-tightening
$ bectl list
BE NAME Active Mountpoint Space Created
default NR / 12.4G 2026-05-01 09:14
pre-pf-tightening - 1.04M 2026-05-03 17:42
If a pf change locks you out and you have console access:
$ bectl activate pre-pf-tightening
$ shutdown -r now
Smoke Test
From a workstation on igc1:
$ ping -c 3 10.10.10.1 # router LAN address
$ ping -c 3 1.1.1.1 # outbound IP routing
$ host www.freebsd.org # outbound DNS
$ traceroute www.freebsd.org # full path
If those four things work, you have a working FreeBSD edge router.
Heads-up: This is the bare metal. The real work — clean pf rules, jails for services, monitoring, IPv6 — lives in the homelab tour and the rest of this series.
Next Steps
- pf.conf: Writing Rules That Survive a Power Outage — replace the bootstrap ruleset with something deliberate
- FreeBSD Jails for Network Services — move DNS and monitoring out of the host
- FreeBSD vs Linux: An SRE's Take — context for why this stack pays off
Building a router along with this guide? Send me your rc.conf. I always learn from how other people draw the lines.
$ subscribe --to newsletter
FreeBSD, pf, and SRE notes — straight to your inbox. No spam, just signal.
Powered by Buttondown. Unsubscribe anytime. Or grab the RSS feed.