🧱 The pf Firewall: Rules, Tables and Redirects
Write a pf ruleset, block and allow real traffic from outside, redirect a port into a jail, ban an address without a reload, and read the log. Tested, including the way to lock yourself out
What pf is
A firewall decides which network traffic may come in, go out or pass through. FreeBSD ships more than one (pf, ipfw and ipfilter, from the documentation), and pf is the most popular. (The ipfw page covers the other main one.) It came from OpenBSD and is also the engine behind pfSense and OPNsense. If you know Linux, it takes the place of iptables/nftables (and of ufw or firewalld on top of them), but its rules read more like English.
Three ideas carry the whole page:
- Rules live in one file, /etc/pf.conf, and are loaded into the kernel with pfctl.
- Rules are checked top to bottom, and the last matching rule wins, unless a rule says quick, which means "stop here, this one wins".
- pf is stateful: when a connection is allowed, pf remembers it and lets the replies through without a new decision.
How we test it
A firewall needs somebody to knock on it from outside. Here the FreeBSD virtual machine (prompt root@practice:~ #) runs the web server inside the jail from the jails page, at 10.0.2.50. The "outside" machine is the laptop running the virtual machine (prompt laptop:~ $), which reaches the jail's web server at 127.0.0.1:18080 through a port forward, and reaches the machine's port 8080 at 127.0.0.1:18081. On your own network you would simply use the server's address. The outside machine appears to the firewall as 10.0.2.2. Everything below was run on FreeBSD 15.0-RELEASE.
root@practice:~ # service pf status pf.ko is not loaded root@practice:~ # pfctl -s info pfctl: /dev/pf: No such file or directory root@practice:~ # ls /usr/share/examples/pf | head -4 ackpri faq-example1 faq-example2 faq-example3 root@practice:~ # sysrc pf_enable pflog_enable pf_enable: NO pflog_enable: NO laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080 | head -3 HTTP/1.1 200 OK Server: nginx/1.30.4 Date: Tue, 06 Oct 2026 00:11:04 GMT
Before any firewall: pf.ko is not loaded, the pfctl tool cannot even open the device, and both pf_enable and pflog_enable are NO. The outside machine fetches the web page fine: HTTP/1.1 200 OK. /usr/share/examples/pf has example rulesets worth reading.
First ruleset: block everything, allow SSH
root@practice:~ # cat > /etc/pf.conf <<'EOF' > ext_if = "vtnet0" > set skip on lo0 > block drop in all > pass out all keep state > pass in on $ext_if proto tcp to port 22 > EOF root@practice:~ # pfctl -nf /etc/pf.conf root@practice:~ # sysrc pf_enable=YES pflog_enable=YES pf_enable: NO -> YES pflog_enable: NO -> YES
The file again is typed with a heredoc, and the > are the shell's continuation prompts. Reading the rules:
- ext_if = "vtnet0" is a macro, a named value. Use $ext_if later, and a change of network card is a one-line edit.
- set skip on lo0 tells pf to leave the loopback interface alone, so the machine can always talk to itself.
- block drop in all: every incoming packet is dropped silently unless a later rule allows it. This is default deny.
- pass out all keep state: anything the machine itself starts may leave, and the replies are let in.
- pass in on $ext_if proto tcp to port 22: SSH may come in. Never forget this line on a remote machine.
pfctl -nf /etc/pf.conf checks the file without loading it (-n means "just parse"). It printed nothing, which means the syntax is fine. (If you see Failed to open netlink instead, the pf kernel module is not loaded yet: run kldload pf first, then repeat the check. service pf start loads it for you, but the check comes before that.) Make this your habit before every load. sysrc pf_enable=YES pflog_enable=YES makes the firewall and its log start at boot, as with any FreeBSD service.
root@practice:~ # service pf start Enabling pf. root@practice:~ # service pf status | head -4 Status: Enabled for 0 days 00:00:01 Debug: Urgent State Table Total Rate current entries 3 root@practice:~ # pfctl -sr block drop in all pass out all flags S/SA keep state pass in on vtnet0 proto tcp from any to any port = ssh flags S/SA keep state
service pf start printed Enabling pf., and the firewall was live. Here is the trap: in our first attempt, the very next command we typed in the same SSH window never came back. The terminal froze. Why? The connection existed before the firewall did, so pf had no state for it. Its next packets were not a new connection (they lacked the SYN flag that the rule flags S/SA looks for), so they hit block drop in all. New SSH connections worked at once, and we simply reconnected. The lesson: enabling pf over SSH can cut your current session. Do it from a console, or have a second connection ready and be prepared to reconnect (see the checklist below).
pfctl -sr shows the rules as pf understood them: pf added flags S/SA keep state to the pass rules by itself (a new connection starts with a SYN, and its state is kept). The service pf status output starts with Status: Enabled.
Drop or return?
From the outside machine, with block drop:
laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080; echo "exit code: $?" exit code: 28
curl waited the full 5 seconds and gave up with exit code 28, "operation timed out": the packet disappeared and the sender has no idea why. This is the stealthy choice: scanners learn nothing, but legitimate users wait for the timeout too. Now the alternative, block return, which sends back a refusal:
root@practice:~ # sed -i '' 's/block drop in all/block return in all/' /etc/pf.conf root@practice:~ # grep block /etc/pf.conf block return in all root@practice:~ # pfctl -nf /etc/pf.conf root@practice:~ # pfctl -f /etc/pf.conf laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080; echo "exit code: $?" exit code: 56
sed -i '' (remember the empty backup name from the overview page) changed the word, we checked the file, validated it (-nf), and reloaded it with pfctl -f /etc/pf.conf. Reloading changes the rules without stopping the firewall or dropping the states of connections already allowed. This time curl failed instantly, with exit code 56 (the connection was reset), instead of waiting. A real remote client would see "Connection refused". (Our laptop reaches the machine through a port forward, which reports a refusal as a reset after connecting.) Rule of thumb: use return for your own network, where fast failures help people, and drop towards the internet.
Opening a port
root@practice:~ # echo 'pass in on $ext_if proto tcp to port 80' >> /etc/pf.conf root@practice:~ # tail -3 /etc/pf.conf pass out all keep state pass in on $ext_if proto tcp to port 22 pass in on $ext_if proto tcp to port 80 root@practice:~ # pfctl -nf /etc/pf.conf root@practice:~ # pfctl -f /etc/pf.conf laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080 | head -3 HTTP/1.1 200 OK Server: nginx/1.30.4 Date: Tue, 06 Oct 2026 00:13:44 GMT
We appended pass in on $ext_if proto tcp to port 80 (single quotes keep $ext_if from being expanded by the shell), checked, reloaded, and the outside machine got the web page: HTTP/1.1 200 OK. To see what each rule has been doing:
laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080 | head -1 HTTP/1.1 200 OK root@practice:~ # pfctl -sr -v 2>/dev/null block return in all [ Evaluations: 16 Packets: 2 Bytes: 80 States: 0 ] [ Source Nodes: 0 Limit: 0 NAT/RDR: 0 Route: 0 ] [ Inserted: uid 0 pid 0 State Creations: 0 ] block drop in log quick proto tcp from <badguys> to any port = http [ Evaluations: 12 Packets: 4 Bytes: 176 States: 0 ] [ Source Nodes: 0 Limit: 0 NAT/RDR: 0 Route: 0 ] [ Inserted: uid 0 pid 0 State Creations: 0 ] pass in on vtnet0 proto tcp from any to any port = ssh flags S/SA keep state [ Evaluations: 8 Packets: 106 Bytes: 15398 States: 1 ] [ Source Nodes: 0 Limit: 0 NAT/RDR: 0 Route: 0 ] [ Inserted: uid 0 pid 0 State Creations: 1 ] pass in on vtnet0 proto tcp from any to any port = http flags S/SA keep state [ Evaluations: 8 Packets: 47 Bytes: 3505 States: 5 ] [ Source Nodes: 0 Limit: 0 NAT/RDR: 0 Route: 0 ] [ Inserted: uid 0 pid 0 State Creations: 5 ] pass out all flags S/SA keep state [ Evaluations: 12 Packets: 4 Bytes: 268 States: 2 ] [ Source Nodes: 0 Limit: 0 NAT/RDR: 0 Route: 0 ] [ Inserted: uid 0 pid 0 State Creations: 2 ]
pfctl -sr -v adds counters to every rule: how often it was evaluated, how many packets and bytes it handled, and how many states it created. Look at the port = http rule: 47 packets and 5 connections (states) so far, while the port = ssh rule shows our own SSH session (106 packets). The first rule, block return in all, caught a couple of packets from our earlier tests. The counters are the quick way to answer "is this rule being used?".
Macros, lists, tables and redirects
Now a ruleset closer to something real:
root@practice:~ # cat > /etc/pf.conf <<'EOF'
> ext_if = "vtnet0"
> tcp_services = "{ 22, 80 }"
> table <badguys> persist
>
> set skip on lo0
>
> rdr pass on $ext_if proto tcp to port 8080 -> 10.0.2.50 port 80
>
> block return in all
> block drop in log quick proto tcp from <badguys> to port 80
> pass out all keep state
> pass in on $ext_if proto tcp to port $tcp_services
> EOF
root@practice:~ # pfctl -nf /etc/pf.conf
root@practice:~ # pfctl -f /etc/pf.conf
root@practice:~ # pfctl -sr
block return in all
block drop in log quick proto tcp from <badguys> to any port = http
pass in on vtnet0 proto tcp from any to any port = ssh flags S/SA keep state
pass in on vtnet0 proto tcp from any to any port = http flags S/SA keep state
pass out all flags S/SA keep state
root@practice:~ # pfctl -sn
rdr pass on vtnet0 inet proto tcp from any to any port = http-alt -> 10.0.2.50 port 80
- tcp_services = "{ 22, 80 }" is a list. to port $tcp_services allows both ports in one line, and pf expands it into separate rules (compare the next listing).
- table <badguys> persist defines a table, a set of addresses that you can change without reloading the rules. persist keeps it even when nothing refers to it.
- block drop in log quick proto tcp from <badguys> to port 80: addresses in the table may not reach port 80. quick makes this rule final, and log records the packets (see below).
- rdr pass on $ext_if proto tcp to port 8080 -> 10.0.2.50 port 80 is a redirect: connections to this machine's port 8080 are sent to port 80 of the jail's address. It is the way to publish a service that runs in a jail or on another machine. Translation rules (rdr, nat) go before the filter rules. The order in a pf.conf is: macros, tables, options, translation, then filter rules.
- pfctl -sr lists the filter rules, where the list has been expanded into one rule for ssh and one for http, and pfctl -sn lists the translation rule.
laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080 | head -2 HTTP/1.1 200 OK Server: nginx/1.30.4 laptop:~ $ curl -sI -m 5 http://127.0.0.1:18081 | head -2 HTTP/1.1 200 OK Server: nginx/1.30.4
Both work from the outside: port 18080 reaches the jail directly, and port 18081 (the machine's 8080) reaches the same web server through the redirect.
Tables: ban an address without a reload
root@practice:~ # pfctl -t badguys -T show root@practice:~ # pfctl -t badguys -T add 10.0.2.2 1/1 addresses added. root@practice:~ # pfctl -t badguys -T show 10.0.2.2 laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080; echo "exit code: $?" exit code: 28 root@practice:~ # pfctl -t badguys -T delete 10.0.2.2 1/1 addresses deleted. laptop:~ $ curl -sI -m 5 http://127.0.0.1:18080 | head -2 HTTP/1.1 200 OK Server: nginx/1.30.4
- The table starts empty. pfctl -t badguys -T add 10.0.2.2 put the outside machine's address in it: 1/1 addresses added. No reload was needed.
- The outside machine's curl now times out (28): the quick rule blocked it. (Our SSH session still worked, because the table rule only covers port 80.)
- pfctl -t badguys -T delete 10.0.2.2 took it out, and the web page was back at once.
That is how tools like fail2ban equivalents ban abusers: they add addresses to a table (from the documentation, not run here). You can also load a table from a file with table <name> persist file "/etc/blocklist".
Reading the log
root@practice:~ # service pflog start Starting pflog. root@practice:~ # ifconfig pflog0 | head -2 pflog0: flags=1000141<UP,RUNNING,PROMISC,LOWER_UP> metric 0 mtu 33152 options=0 root@practice:~ # pfctl -f /etc/pf.conf root@practice:~ # pfctl -t badguys -T add 10.0.2.2 1/1 addresses added. laptop:~ $ curl -sI -m 3 http://127.0.0.1:18080; echo "exit code: $?" exit code: 28 root@practice:~ # cat /tmp/pflog.txt tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on pflog0, link-type PFLOG (OpenBSD pflog file), snapshot length 262144 bytes 00:14:39.716090 rule 1/0(match): block in on vtnet0: 10.0.2.2.42368 > 10.0.2.50.80: Flags [S], seq 33216001, win 65535, options [mss 1460], length 0 00:14:40.270411 rule 1/0(match): block in on vtnet0: 10.0.2.2.33906 > 10.0.2.50.80: Flags [S], seq 35712001, win 65535, options [mss 1460], length 0 2 packets captured 2 packets received by filter 0 packets dropped by kernel root@practice:~ # pfctl -t badguys -T delete 10.0.2.2 1/1 addresses deleted.
pflog is a small service that records the packets of every rule marked log on a special interface, pflog0. We started it by hand, reloaded the rules so they attach to it, banned the address again and used tcpdump on pflog0. It recorded two blocked connection attempts: the time, block in on vtnet0, the source 10.0.2.2 and the destination 10.0.2.50.80, with the TCP flag S (a new connection). At boot, pflog_enable=YES starts the log service before the firewall (from the documentation, not run here), and the log also goes to /var/log/pflog for later reading with tcpdump -r (also from the documentation).
root@practice:~ # pfctl -si | head -14 Status: Enabled for 0 days 00:00:55 Debug: Urgent State Table Total Rate current entries 8 searches 2826 51.4/s inserts 10 0.2/s removals 2 0.0/s Counters match 47 0.9/s bad-offset 0 0.0/s fragment 0 0.0/s short 0 0.0/s normalize 0 0.0/s memory 0 0.0/s
pfctl -si prints the overall statistics: how long the firewall has been enabled, how many states exist, and counters. And the emergency exits:
root@practice:~ # pfctl -d pf disabled root@practice:~ # pfctl -e pf enabled
pfctl -d disables pf and pfctl -e enables it again. If a ruleset locks you out and you still have a shell (the console, or a connection that survives), pfctl -d is the escape hatch.
A safety checklist
- Always run pfctl -nf /etc/pf.conf before loading. No output means the syntax is fine. On a machine where pf has never run, load the module first with kldload pf.
- Keep a second connection or the console open when you change firewall rules on a remote machine. A rule can cut you off even when the syntax is right.
- Put the SSH rule first in your mind, and in your file. Test with a new connection before closing the old one.
- If you must enable pf over an existing SSH session, consider adding flags any to the SSH pass rule for the first load, so that the existing connection continues. Then take it out and reload (from the documentation, not run here).
- Remember the order: last match wins, except for quick. If a rule seems ignored, a later rule or an earlier quick rule is why.
- To change a running firewall, edit the file, check it, then pfctl -f /etc/pf.conf. Tables can be changed live.
- Not covered here and tested only in the documentation: NAT for a network behind the machine (nat on $ext_if from 10.0.0.0/24 to any -> ($ext_if) and gateway_enable=YES in /etc/rc.conf), rate limiting with max-src-conn-rate and overload tables, anchors (named sub-rulesets), antispoof, and queueing (this kernel printed No ALTQ support in kernel).
pf for Linux people
- ufw allow 22 or iptables -A INPUT -p tcp --dport 22 -j ACCEPT is pass in proto tcp to port 22.
- ufw default deny incoming is block in all. iptables -j DROP and REJECT are block drop and block return.
- iptables -t nat ... DNAT is rdr. ipset is a pf table.
- iptables -L -v is pfctl -sr -v. conntrack -L is pfctl -ss. iptables-restore is pfctl -f.
- And no chains to keep in your head: one file, read top to bottom.
⚠️ Do not test firewall changes on a remote production server first. Try them on a virtual machine like this one. If you must change a remote machine, keep the console reachable, and check the file with pfctl -nf before you load it.
The bouncer says: "Not on this port."
🧪 Try it yourself
In a FreeBSD virtual machine (never first on the production server), with a service to test (a jail or any listening program):
1. Write a ruleset with a macro for the interface, set skip on lo0, block drop in all, pass out all keep state and a rule for SSH. Check it with pfctl -nf. 2. Enable pf and reconnect if your SSH window freezes. What do you see in pfctl -sr? 3. From another machine, try the service port. Is it blocked? How long does curl take to fail? 4. Change block drop to block return. Does the failure arrive faster? 5. Open the port with a list, for example { 80, 443 }, reload, and check the counters with pfctl -sr -v. 6. Create a table, add the other machine's address, and watch the service become unreachable. Remove it again. 7. Add a rdr rule to publish an internal service on another port. 8. Add log to one block rule, start pflog, reload, and watch tcpdump -n -e -i pflog0.
The cheat sheet
- Files and services: /etc/pf.conf, sysrc pf_enable=YES, sysrc pflog_enable=YES, service pf start|stop|status|reload.
- Check and load: pfctl -nf /etc/pf.conf (check only), pfctl -f /etc/pf.conf (load), pfctl -d (disable), pfctl -e (enable).
- Look: pfctl -sr (filter rules), pfctl -sr -v (with counters), pfctl -sn (translation rules), pfctl -ss (states), pfctl -si (statistics).
- Rules: block drop|return in all, pass in on $ext_if proto tcp to port { 80, 443 }, pass out all keep state, quick, log.
- Tables: table <name> persist, pfctl -t NAME -T add|delete|show ADDRESS.
- Redirect: rdr pass on $ext_if proto tcp to port 8080 -> 10.0.2.50 port 80.
- Log: service pflog start, tcpdump -n -e -i pflog0.
- Order: last match wins, except quick. Macros, tables, translation, then filter rules.
← Jails: Lightweight Containers, the BSD Way · ↑ FreeBSD Corner · The Ports Tree: Building FreeBSD Software from Source →