Building a FreeBSD pf Router

Published on

FreeBSD pf Networking Homelab

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:

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:

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:

/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

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.

Related Posts

← Back to Blog