FreeBSD ships two production-grade firewalls: PF (Packet Filter, ported from OpenBSD) and IPFW (IP Firewall, native to FreeBSD). Both are mature, well-documented, and used in critical infrastructure worldwide. This guide covers both in full, with real configs you can deploy today.
If you need a quick feature comparison before diving in, see our PF vs IPFW breakdown. For a PF-only deep dive, see PF Firewall on FreeBSD.
1. PF vs IPFW — When to Use Each
| Criteria | PF | IPFW |
|---|---|---|
| Default in FreeBSD | No (must load kernel module) | Available, not enabled by default |
| Rule evaluation | Last match wins | First match wins (unless check-state) |
| NAT syntax | Built-in (nat-to, rdr-to) | Separate (ipfw nat or natd) |
| Traffic shaping | Via ALTQ (limited on FreeBSD) | Via dummynet (superior on FreeBSD) |
| HA failover | CARP + pfsync (native) | Manual, no pfsync equivalent |
| Stateful inspection | On by default | Via keep-state / check-state |
| Familiar from | OpenBSD, pfSense, OPNsense | FreeBSD jails, embedded systems |
Choose PF if you need CARP failover, come from an OpenBSD/pfSense background, or want built-in NAT syntax. Choose IPFW if you need dummynet traffic shaping, fine-grained rule numbering, or are building a FreeBSD jail host. For a deeper comparison, see Best Firewall for FreeBSD.
2. PF Complete Reference
2.1 Enabling PF
Load the kernel module and enable PF at boot:
sh# Enable PF and pflog in rc.conf sysrc pf_enable="YES" sysrc pf_rules="/etc/pf.conf" sysrc pflog_enable="YES" sysrc pflog_logfile="/var/log/pflog"
Start PF immediately without rebooting:
sh# Load the kernel module kldload pf kldload pflog # Start PF service pf start service pflog start
Core pfctl commands:
sh# Load/reload rules pfctl -f /etc/pf.conf # Enable/disable PF pfctl -e pfctl -d # Show current ruleset pfctl -sr # Show state table pfctl -ss # Show statistics pfctl -si # Flush all rules, NAT, tables pfctl -F all
2.2 Rule Syntax and Evaluation Order
PF evaluates rules top to bottom. The last matching rule wins, unless a rule uses the quick keyword, which causes an immediate match and stops evaluation.
Basic rule structure:
shellaction [direction] [log] [quick] [on interface] [af] [proto protocol] \ [from src_addr [port src_port]] [to dst_addr [port dst_port]] \ [flags flags] [state]
Examples:
sh# Block everything by default block all # Pass SSH on the external interface — quick stops evaluation pass in quick on egress proto tcp to port 22 # Pass outbound traffic, keep state pass out quick on egress proto { tcp udp } all keep state
The quick keyword is critical. Without it, a later block all would override an earlier pass rule. Most production rulesets use quick on every rule to get first-match behavior similar to IPFW.
2.3 Macros and Tables
Macros are variables. Tables are optimized address sets for fast lookups.
sh# Macros ext_if = "vtnet0" int_if = "vtnet1" tcp_services = "{ ssh, http, https }" trusted_nets = "{ 10.0.0.0/8, 172.16.0.0/12 }" # Tables — loaded from file or defined inline table <bruteforce> persist table <rfc1918> const { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } # Use in rules block in quick on $ext_if from <bruteforce> pass in quick on $ext_if proto tcp to port $tcp_services block in quick on $ext_if from <rfc1918>
Manage tables at runtime:
sh# Add an IP to the bruteforce table pfctl -t bruteforce -T add 203.0.113.50 # Remove an IP pfctl -t bruteforce -T delete 203.0.113.50 # Show table contents pfctl -t bruteforce -T show # Flush a table pfctl -t bruteforce -T flush # Load addresses from a file pfctl -t bruteforce -T replace -f /etc/pf.bruteforce
2.4 NAT and Port Forwarding
PF handles NAT and redirection natively. For a detailed NAT guide, see NAT with PF on FreeBSD.
Outbound NAT (source NAT):
shext_if = "vtnet0" # NAT all internal traffic going out nat on $ext_if from 10.0.0.0/24 to any -> ($ext_if) # Using nat-to syntax (FreeBSD 13+) pass out on $ext_if from 10.0.0.0/24 nat-to ($ext_if)
The parentheses around ($ext_if) tell PF to track address changes (useful for DHCP interfaces).
Port forwarding (destination NAT):
sh# Forward port 80 from external IP to internal web server rdr on $ext_if proto tcp from any to ($ext_if) port 80 -> 10.0.0.10 port 80 # Using rdr-to syntax (FreeBSD 13+) pass in on $ext_if proto tcp to ($ext_if) port 80 rdr-to 10.0.0.10 port 80
Port range forwarding:
sh# Forward a port range rdr on $ext_if proto tcp from any to ($ext_if) port 8000:8100 -> 10.0.0.10
Enable IP forwarding for NAT to work:
shsysrc gateway_enable="YES" sysctl net.inet.ip.forwarding=1
2.5 Rate Limiting and Brute-Force Protection
PF can limit connection rates per source IP, which is highly effective against brute-force attacks:
sh# Block hosts with more than 5 SSH connections in 30 seconds pass in quick on $ext_if proto tcp to port ssh \ flags S/SA keep state \ (max-src-conn 15, max-src-conn-rate 5/30, \ overload <bruteforce> flush global) # Block the overloaded IPs block in quick on $ext_if from <bruteforce>
This rule:
- Allows a maximum of 15 simultaneous connections per source IP (
max-src-conn 15) - Triggers when a source exceeds 5 new connections in 30 seconds (
max-src-conn-rate 5/30) - Adds the offending IP to the
table (overload) - Kills all existing states from that IP (
flush global)
Set up a cron job to expire old entries:
sh# Expire entries older than 24 hours — add to crontab 0 * * * * /sbin/pfctl -t bruteforce -T expire 86400
2.6 Anchors
Anchors let you load sub-rulesets dynamically without reloading the main ruleset. They are useful for applications like fail2ban, VPN rules, or per-jail firewalls.
In /etc/pf.conf:
sh# Define anchor points anchor "fail2ban/*" anchor "jails/*"
Load rules into an anchor at runtime:
sh# Add rules to an anchor echo "block in quick from 198.51.100.5" | pfctl -a "fail2ban/ssh" -f - # Show rules in an anchor pfctl -a "fail2ban/ssh" -sr # Flush an anchor pfctl -a "fail2ban/ssh" -F rules # Load from a file pfctl -a "jails/web01" -f /etc/pf.jail.web01.conf
2.7 Logging with pflog
PF logs packets to the pflog0 interface. Add log to any rule to capture matching packets:
shblock in log on $ext_if all pass in log (all) quick on $ext_if proto tcp to port ssh
The log (all) modifier logs every packet in the connection, not just the initial one.
Read the log:
sh# Live capture tcpdump -n -e -ttt -i pflog0 # Read the binary log file tcpdump -n -e -ttt -r /var/log/pflog # Filter by rule number tcpdump -n -e -ttt -i pflog0 rulenum 4 # Filter by action tcpdump -n -e -ttt -i pflog0 action block
Configure log rotation in /etc/newsyslog.conf:
shell/var/log/pflog 600 7 * @T00 JB /var/run/pflogd.pid
2.8 Complete Production Web Server Ruleset
A full /etc/pf.conf for a web server facing the internet:
sh# --- Macros --- ext_if = "vtnet0" tcp_services = "{ ssh, http, https }" icmp_types = "{ echoreq, unreach }" # --- Tables --- table <bruteforce> persist table <rfc1918> const { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } # --- Options --- set skip on lo0 set block-policy drop set loginterface $ext_if set state-policy if-bound set optimization aggressive # --- Scrub --- scrub in all fragment reassemble no-df # --- Block by default --- block all block in quick on $ext_if from <bruteforce> # --- Anti-spoof --- antispoof quick for $ext_if block in quick on $ext_if from <rfc1918> # --- Pass rules --- # Outbound pass out quick on $ext_if proto { tcp udp } all modulate state pass out quick on $ext_if proto icmp all keep state # ICMP pass in quick on $ext_if inet proto icmp icmp-type $icmp_types keep state # SSH with brute-force protection pass in quick on $ext_if proto tcp to port ssh \ flags S/SA modulate state \ (max-src-conn 15, max-src-conn-rate 5/30, \ overload <bruteforce> flush global) # HTTP/HTTPS pass in quick on $ext_if proto tcp to port { http, https } \ flags S/SA modulate state # DNS out (for the server itself) pass out quick on $ext_if proto { tcp udp } to port domain keep state # NTP out pass out quick on $ext_if proto udp to port ntp keep state
For general server hardening beyond the firewall, see Hardening a FreeBSD Server.
3. IPFW Complete Reference
3.1 Enabling IPFW
Enable IPFW at boot:
shsysrc firewall_enable="YES" sysrc firewall_script="/etc/ipfw.rules" sysrc firewall_logging="YES"
For a permissive start while configuring (avoids lockout):
shsysrc firewall_type="open"
Once your rules are ready, switch to your custom script:
shsysrc firewall_type="/etc/ipfw.rules"
Load the kernel module manually:
shkldload ipfw
Warning: Loading IPFW without firewall_type="open" or a permissive default rule will lock you out of a remote machine. Always set a default-allow rule first when working remotely, or use a serial/KVM console.
Core ipfw commands:
sh# Show all rules ipfw list # Show rules with timestamps ipfw -t list # Show dynamic rules ipfw -d list # Show packet/byte counters ipfw -a list # Flush all rules (dangerous!) ipfw -f flush # Add a rule ipfw add 100 allow tcp from any to me 22 # Delete a rule by number ipfw delete 100 # Zero counters ipfw zero
3.2 Rule Syntax and Numbering
IPFW uses numbered rules evaluated in ascending order. The first matching rule wins (unless skipto or check-state redirects evaluation).
Basic syntax:
shellipfw add [rule_number] [set set_number] [prob probability] action [log] proto \ from src to dst [options]
Key actions: allow (or pass/accept/permit), deny (or drop), reject, check-state, skipto, nat, pipe, queue.
Examples:
sh# Allow established connections (stateful) ipfw add 100 check-state :default # Allow SSH in ipfw add 200 allow tcp from any to me 22 setup keep-state :default # Allow HTTP and HTTPS in ipfw add 300 allow tcp from any to me 80,443 setup keep-state :default # Allow all outbound ipfw add 400 allow ip from me to any keep-state :default # Allow ICMP ipfw add 500 allow icmp from any to me icmptypes 0,3,8,11 # Deny everything else ipfw add 65000 deny log ip from any to any
The :default label tags state entries so check-state can match them. Rule 65534 is the built-in default rule (deny or allow, depending on kernel option IPFIREWALL_DEFAULT_TO_ACCEPT).
3.3 NAT with ipfw nat
Modern FreeBSD uses in-kernel NAT via ipfw nat, which is faster than the older natd daemon.
sh# Enable IP forwarding sysrc gateway_enable="YES" sysctl net.inet.ip.forwarding=1
NAT configuration in your IPFW rules:
sh#!/bin/sh # Define interfaces ext_if="vtnet0" int_if="vtnet1" ext_ip="203.0.113.10" # Flush existing rules ipfw -f flush # Configure NAT instance 1 ipfw nat 1 config if $ext_if reset same_ports deny_in redirect_port tcp 10.0.0.10:80 80 # Divert outbound traffic from internal net through NAT ipfw add 10 nat 1 ip from 10.0.0.0/24 to any out via $ext_if # Divert inbound traffic back through NAT ipfw add 20 nat 1 ip from any to $ext_ip in via $ext_if # Stateful firewall after NAT ipfw add 100 check-state :default ipfw add 200 allow tcp from any to 10.0.0.10 80 setup keep-state :default in via $int_if ipfw add 300 allow ip from 10.0.0.0/24 to any keep-state :default ipfw add 65000 deny log ip from any to any
The redirect_port tcp 10.0.0.10:80 80 line forwards port 80 to the internal web server. Multiple redirect_port entries can be chained.
3.4 Dynamic Rules
IPFW creates dynamic rules when a rule uses keep-state or limit. Dynamic rules enable stateful filtering.
sh# Allow outbound and track state ipfw add 100 check-state :default ipfw add 200 allow tcp from me to any setup keep-state :default # Limit connections per source IP (anti brute-force) ipfw add 300 allow tcp from any to me 22 setup limit src-addr 5 :default
Tune dynamic rule behavior:
sh# In /etc/sysctl.conf or via sysctl sysctl net.inet.ip.fw.dyn_max=65536 sysctl net.inet.ip.fw.dyn_ack_lifetime=300 sysctl net.inet.ip.fw.dyn_syn_lifetime=20 sysctl net.inet.ip.fw.dyn_fin_lifetime=5
3.5 Traffic Shaping with Dummynet
Dummynet is IPFW's built-in traffic shaper. It is one of the key advantages IPFW has over PF on FreeBSD.
sh# Load dummynet kldload dummynet
Pipes provide fixed bandwidth limits. Queues provide weighted fair sharing within a pipe.
sh# Create a 10 Mbit pipe with 20ms delay ipfw pipe 1 config bw 10Mbit/s delay 20 # Create a 1 Mbit pipe for throttling ipfw pipe 2 config bw 1Mbit/s # Shape all traffic from a subnet through pipe 1 ipfw add 100 pipe 1 ip from 10.0.0.0/24 to any # Throttle a specific host ipfw add 200 pipe 2 ip from 10.0.0.50 to any # Weighted queues sharing a pipe ipfw pipe 10 config bw 100Mbit/s ipfw queue 1 config pipe 10 weight 70 # High priority (70%) ipfw queue 2 config pipe 10 weight 30 # Low priority (30%) # Assign traffic to queues ipfw add 300 queue 1 tcp from any to me 80,443 ipfw add 400 queue 2 ip from any to any # Per-host shaping with masks (gives each IP its own pipe) ipfw pipe 3 config bw 5Mbit/s mask src-ip 0xffffffff ipfw add 500 pipe 3 ip from 10.0.0.0/24 to any
Enable at boot:
shsysrc dummynet_enable="YES"
3.6 Logging
IPFW logs to syslog when a rule includes log:
shipfw add 65000 deny log ip from any to any
Limit log verbosity:
sh# Log only the first 10 packets matching this rule ipfw add 65000 deny log logamount 10 ip from any to any
Configure syslog to capture IPFW messages. In /etc/syslog.conf:
shellsecurity.* /var/log/ipfw.log
Tune in /etc/sysctl.conf:
sh# Enable verbose logging net.inet.ip.fw.verbose=1 # Limit log entries per rule (0 = unlimited) net.inet.ip.fw.verbose_limit=100
Create the log file and restart syslog:
shtouch /var/log/ipfw.log service syslogd restart
3.7 Complete Production Ruleset
A full /etc/ipfw.rules for a web server:
sh#!/bin/sh # Flush existing rules ipfw -q -f flush # Loopback ipfw -q add 10 allow ip from any to any via lo0 # Stateful engine ipfw -q add 100 check-state :default # Anti-spoof: deny private addresses on external interface ipfw -q add 110 deny ip from 10.0.0.0/8 to any in via vtnet0 ipfw -q add 111 deny ip from 172.16.0.0/12 to any in via vtnet0 ipfw -q add 112 deny ip from 192.168.0.0/16 to any in via vtnet0 # ICMP ipfw -q add 200 allow icmp from any to me icmptypes 0,3,8,11 in via vtnet0 ipfw -q add 201 allow icmp from me to any out via vtnet0 # SSH with connection limit (max 5 concurrent per source IP) ipfw -q add 300 allow tcp from any to me 22 setup limit src-addr 5 :default in via vtnet0 # HTTP / HTTPS ipfw -q add 400 allow tcp from any to me 80,443 setup keep-state :default in via vtnet0 # DNS, NTP outbound (server's own lookups) ipfw -q add 500 allow tcp from me to any 53 setup keep-state :default out via vtnet0 ipfw -q add 501 allow udp from me to any 53 keep-state :default out via vtnet0 ipfw -q add 502 allow udp from me to any 123 keep-state :default out via vtnet0 # Allow all outbound TCP/UDP (stateful) ipfw -q add 600 allow tcp from me to any setup keep-state :default out via vtnet0 ipfw -q add 601 allow udp from me to any keep-state :default out via vtnet0 # Deny and log everything else ipfw -q add 65000 deny log logamount 50 ip from any to any
Make it executable:
shchmod 755 /etc/ipfw.rules
4. CARP Firewall Failover
CARP (Common Address Redundancy Protocol) with pfsync gives you active/passive firewall failover with PF. Both firewalls share a virtual IP; if the master dies, the backup takes over in sub-second time. For a full CARP setup guide, see FreeBSD CARP High Availability.
Architecture
- Firewall A (master): priority
advskew 0 - Firewall B (backup): priority
advskew 100 - pfsync interface: synchronizes state tables between both hosts
- CARP VIP: the shared address clients connect to
Configuration on Both Hosts
/etc/rc.conf on Firewall A (master):
sh# Physical interfaces ifconfig_vtnet0="inet 203.0.113.10/24" ifconfig_vtnet1="inet 10.0.0.1/24" # pfsync — dedicated sync interface ifconfig_vtnet2="inet 192.168.255.1/30" pfsync_enable="YES" pfsync_syncdev="vtnet2" # CARP VIP on external interface ifconfig_vtnet0_alias0="vhid 1 advskew 0 pass carpSecretKey alias 203.0.113.100/32" # CARP VIP on internal interface ifconfig_vtnet1_alias0="vhid 2 advskew 0 pass carpSecretKey alias 10.0.0.254/32" # PF pf_enable="YES" pflog_enable="YES" gateway_enable="YES"
On Firewall B (backup), change the physical IPs and set advskew 100:
shifconfig_vtnet0="inet 203.0.113.11/24" ifconfig_vtnet1="inet 10.0.0.2/24" ifconfig_vtnet2="inet 192.168.255.2/30" pfsync_enable="YES" pfsync_syncdev="vtnet2" ifconfig_vtnet0_alias0="vhid 1 advskew 100 pass carpSecretKey alias 203.0.113.100/32" ifconfig_vtnet1_alias0="vhid 2 advskew 100 pass carpSecretKey alias 10.0.0.254/32"
PF Rules for CARP/pfsync
Add these to /etc/pf.conf on both hosts:
sh# Allow pfsync traffic on the sync interface pass quick on vtnet2 proto pfsync keep state # Allow CARP traffic on all interfaces pass quick proto carp keep state
Verify CARP status:
shifconfig -g carp sysctl net.inet.carp.allow=1
5. Testing and Debugging
PF Debugging
sh# Show loaded rules with rule numbers pfctl -sr -v # Show NAT rules pfctl -sn # Show state table (active connections) pfctl -ss # Show state table with rule numbers pfctl -ss -v # Show interface statistics pfctl -si # Show memory usage pfctl -sm # Show everything pfctl -sa # Test a ruleset without loading it (syntax check) pfctl -n -f /etc/pf.conf # Live packet capture on pflog0 tcpdump -n -e -ttt -i pflog0 # Show which rule blocked/passed a packet tcpdump -n -e -ttt -i pflog0 | grep "rule"
IPFW Debugging
sh# Show all rules with counters ipfw -a list # Show dynamic rules ipfw -d list # Show rules with timestamps ipfw -t list # Show NAT instances ipfw nat show config # Zero all counters (useful for testing) ipfw zero # Show specific rule ipfw show 300 # Log counters by rule ipfw -c list
Packet Capture with tcpdump
Regardless of which firewall you use, tcpdump is the definitive debugging tool:
sh# Capture all traffic on an interface tcpdump -n -i vtnet0 # Capture only TCP SYN packets (new connections) tcpdump -n -i vtnet0 'tcp[tcpflags] & tcp-syn != 0' # Capture traffic to/from a specific host tcpdump -n -i vtnet0 host 203.0.113.50 # Capture traffic on a specific port tcpdump -n -i vtnet0 port 443 # Write capture to file for later analysis tcpdump -n -i vtnet0 -w /tmp/capture.pcap # Read a capture file tcpdump -n -r /tmp/capture.pcap
Remote Lockout Prevention
When working on a remote machine, always set a safety net before changing firewall rules:
sh# PF: Schedule a rule flush in 5 minutes (cancel with Ctrl+C if rules work) echo "pfctl -d" | at now + 5 minutes # IPFW: Schedule a flush echo "ipfw -f flush && ipfw add 65535 allow ip from any to any" | at now + 5 minutes
6. Migration Between PF and IPFW
PF to IPFW Translation
| PF | IPFW |
|---|---|
| block all | ipfw add 65000 deny ip from any to any |
| pass in quick on vtnet0 proto tcp to port 22 | ipfw add 200 allow tcp from any to me 22 in via vtnet0 setup keep-state |
| pass out quick keep state | ipfw add 100 check-state + ipfw add 600 allow ip from me to any keep-state |
| nat on vtnet0 ... -> (vtnet0) | ipfw nat 1 config if vtnet0 + ipfw add 10 nat 1 ip from ... to any out via vtnet0 |
| table | No direct equivalent; use ipfw table (FreeBSD 11+) |
| max-src-conn-rate 5/30 overload | ipfw add ... limit src-addr 5 (less flexible) |
IPFW to PF Translation
| IPFW | PF |
|---|---|
| check-state | Automatic (PF is stateful by default) |
| keep-state | keep state (default, can be omitted) |
| limit src-addr 5 | max-src-conn 5 |
| pipe 1 config bw 10Mbit/s | No direct dummynet equivalent in PF on FreeBSD |
| ipfw table 1 add 10.0.0.0/8 | pfctl -t mytable -T add 10.0.0.0/8 |
Migration Steps
- Write the new ruleset for the target firewall alongside the current one.
- Syntax-check without loading:
pfctl -n -f /etc/pf.confor reviewipfwcommands withechofirst. - Schedule an automatic disable/flush (see lockout prevention above).
- Disable the old firewall, enable the new one.
- Test connectivity immediately.
- Monitor logs for the first 24 hours.
sh# Switch from IPFW to PF sysrc firewall_enable="NO" service ipfw stop sysrc pf_enable="YES" sysrc pflog_enable="YES" service pf start service pflog start # Switch from PF to IPFW sysrc pf_enable="NO" service pf stop sysrc firewall_enable="YES" sysrc firewall_script="/etc/ipfw.rules" service ipfw start
7. FAQ
Can I run PF and IPFW at the same time?
Technically yes — both kernel modules can be loaded simultaneously and packets will pass through both. Practically, do not do this. The interaction between two stateful firewalls creates unpredictable behavior, makes debugging nearly impossible, and doubles the performance overhead. Pick one.
Will changing firewall rules drop existing connections?
With PF, reloading rules via pfctl -f /etc/pf.conf preserves existing state entries. Connections already tracked in the state table continue uninterrupted. Only new connections are evaluated against the new ruleset. With IPFW, flushing and reloading rules (ipfw -f flush) kills all dynamic states. To avoid this, add new rules by number and delete old ones individually rather than flushing.
How do I block a country or large IP list?
With PF, load the list into a table:
sh# Download and load a blocklist fetch -o /etc/pf.blocklist https://example.com/blocklist.txt pfctl -t blocklist -T replace -f /etc/pf.blocklist
With IPFW (FreeBSD 11+), use ipfw table:
shipfw table 1 create type addr ipfw table 1 add 198.51.100.0/24 ipfw table 1 add 203.0.113.0/24 ipfw add 50 deny ip from 'table(1)' to any
PF tables are significantly faster for large sets (hundreds of thousands of entries) due to their radix tree implementation.
What is the performance difference between PF and IPFW?
For most workloads (under 10 Gbps, under 1 million concurrent states), the difference is negligible. Both firewalls operate in kernel space and handle modern server traffic without measurable overhead. IPFW is marginally faster at raw packet throughput because it was designed for FreeBSD's network stack from the start. PF is marginally faster at table lookups for large address sets. Neither will be your bottleneck unless you are building a 40 Gbps+ router.
Summary
Both PF and IPFW are production-ready firewalls that ship with FreeBSD. PF gives you cleaner NAT syntax, CARP failover, and overload tables for brute-force protection. IPFW gives you dummynet traffic shaping, fine-grained rule numbering, and tighter integration with FreeBSD internals.
For most web servers and network appliances, either will serve you well. Pick the one that matches your operational experience and the features you actually need.
Related guides: