🧱 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

Video: The pf Firewall (FreeBSD video)
Prefer to watch? This short video runs this page's main commands on a real FreeBSD 15.0 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.

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:

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:

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
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

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

pf for Linux people

⚠️ 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.

A firewall admin walks into a bar.<br>
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

← Jails: Lightweight Containers, the BSD Way · ↑ FreeBSD Corner · The Ports Tree: Building FreeBSD Software from Source →