Skip to content
daniel@cosenza:~/blog
FreeBSDDeep Dive May 8, 2026 4 min read

Configuring pf on FreeBSD: A Practical Guide to Packet Filtering

How pf's rule evaluation model, tables, and anchors fit together on FreeBSD, with a ruleset you can adapt for a real host.

FreeBSD ships with three firewall packages — pf, ipfw, and ipfilter — but pf, ported over from OpenBSD, is the one most FreeBSD deployments reach for by default. Its rule syntax reads closer to prose than the terser ipfw alternative, and its state tracking, tables, and anchors make it comfortable for anything from a home router to a multi-homed server.

Enabling pf

pf is a kernel module plus a userland control utility, pfctl. Enabling it is a matter of loading the module and pointing it at a ruleset file:

# /etc/rc.conf
pf_enable="YES"
pf_rules="/etc/pf.conf"
pflog_enable="YES"
service pf start
pfctl -sr        # show the currently loaded ruleset

The evaluation model: last match wins

The single most important thing to internalize about pf is that, unlike many firewalls, rules are evaluated in order and the last matching rule wins — not the first. A block rule near the top of the file can still be overridden by a pass rule further down that also matches the packet. This is what makes pf’s common idiom — a blanket default-deny followed by successive narrower pass exceptions — read naturally from top to bottom:

# /etc/pf.conf
ext_if = "vtnet0"

set skip on lo

block in all
pass out all keep state

pass in on $ext_if proto tcp to port 22 keep state
pass in on $ext_if proto tcp to port { 80, 443 } keep state

The quick keyword opts out of “last match wins” for a specific rule, causing evaluation to stop immediately if it matches — useful for hard denials you never want a later rule to accidentally override:

block quick from <bruteforce>

Tables: dynamic lists of addresses

A table is a named, efficiently-indexed set of addresses that rules can reference, and that can be updated at runtime without reloading the whole ruleset — the mechanism behind most pf-based brute-force protection:

table <bruteforce> persist
block quick from <bruteforce>
# Add an offending address at runtime
pfctl -t bruteforce -T add 203.0.113.9

# List current table contents
pfctl -t bruteforce -T show

Tools like sshguard work by parsing logs and calling pfctl -T add against a table like this one — the table is the mechanism, the log-watching daemon is just a client of it.

State tracking and keep state

keep state tells pf to record a connection’s state after the first packet matches, so subsequent packets belonging to the same flow (return traffic, in particular) are matched against the state table directly rather than re-evaluated against the whole ruleset. In modern pf, pass rules default to flags S/SA keep state implicitly for TCP, but writing it explicitly is good practice for readability:

pfctl -ss    # dump the current state table

A ruleset with no pass ... keep state for outbound traffic and a strict block in will silently break return traffic on a stateless setup — one of the more common early debugging traps.

Anchors: composable sub-rulesets

An anchor is a named, separate ruleset that can be loaded, reloaded, or removed independently of the main pf.conf — useful for rules generated by another process (a container runtime, a VPN daemon) that shouldn’t require hand-editing the master file:

anchor "docker/*"
load anchor "docker" from "/etc/pf.anchors/docker"
# Replace just the "docker" anchor's rules without touching anything else
pfctl -a docker -f /etc/pf.anchors/docker

NAT and redirection

pf handles NAT in the same configuration file, using nat and rdr (redirect) rules — the latter is what powers port forwarding to a jail or an internal host:

nat on $ext_if from 192.168.1.0/24 to any -> ($ext_if)
rdr on $ext_if proto tcp to port 8080 -> 192.168.1.50 port 80

Logging and inspecting traffic

pflog_enable="YES" creates a pflog0 pseudo-interface that any rule tagged with log writes matching packets to, readable with tcpdump:

block in log quick from <bruteforce>
tcpdump -n -e -ttt -i pflog0

Testing a ruleset before committing to it

pfctl -nf /etc/pf.conf parses and validates a ruleset without loading it — syntax errors and unresolvable macros surface immediately, which matters a great deal on a remote box where a bad block in all with no matching pass could lock out your only SSH session:

pfctl -nf /etc/pf.conf && pfctl -f /etc/pf.conf

That one habit — always dry-running with -n before a live reload — is the difference between a routine config change and a trip to the data center.