# How to Set Up PF Firewall on FreeBSD
PF (Packet Filter) is one of the most capable firewalls available on any Unix system. Originally developed for OpenBSD in 2001 as a replacement for IPFilter, PF was ported to FreeBSD starting with version 5.3 and has been a first-class citizen of the FreeBSD networking stack ever since. If you run FreeBSD in production, PF is the firewall you should know.
This guide walks through everything from enabling PF to deploying complete production rulesets for web servers and gateways. Every example has been tested on FreeBSD 14.x, but the syntax applies to 13.x as well.
Why PF on FreeBSD
FreeBSD ships three firewalls: IPFW, IPFilter, and PF. Each has its strengths. PF wins on three fronts:
- **Readable syntax.** PF rules read close to plain English. A deny rule looks like block in all -- no flag soup, no implicit state tracking confusion.
- **Stateful by default.** PF tracks connection state automatically. You write a pass rule for outbound traffic and reply packets flow back without explicit rules.
- **Powerful primitives.** Tables, anchors, overload detection, OS fingerprinting, and adaptive timeouts are built into the grammar. You do not need external scripts for brute-force protection or dynamic blocklists.
PF also powers pfSense and OPNsense, so skills transfer directly to those platforms. If you are following a [FreeBSD hardening](/blog/hardening-freebsd-server/) workflow, PF is the natural firewall choice.
Enabling PF in /etc/rc.conf
PF is compiled into the FreeBSD GENERIC kernel but not enabled by default. Add these lines to /etc/rc.conf:
sh
pf_enable="YES"
pf_rules="/etc/pf.conf"
pflog_enable="YES"
pflog_logfile="/var/log/pflog"
The pflog_enable line creates the pflog0 interface used for packet logging. Without it, log keywords in your rules do nothing useful.
Start PF immediately without rebooting:
sh
service pf start
service pflog start
If you are connected via SSH, be careful. Starting PF with an empty or broken ruleset will lock you out. Always have console access or an out-of-band management path before enabling PF on a remote machine.
pf.conf Structure and Syntax
PF reads its configuration from /etc/pf.conf. The file is processed top to bottom, but the **last matching rule wins** (not the first). This is the single most important thing to understand about PF rule evaluation.
A well-organized pf.conf follows this order:
1. **Macros** -- variable definitions
2. **Tables** -- IP address sets
3. **Options** -- global tuning (timeouts, limits, optimization)
4. **Scrub/Match** -- packet normalization
5. **NAT/RDR** -- address translation (on FreeBSD, use nat-to and rdr-to within match or pass rules)
6. **Filter rules** -- block/pass decisions
Macros
Macros simplify rule management. Define them at the top of pf.conf:
ext_if = "vtnet0"
int_if = "vtnet1"
ssh_port = "22"
web_ports = "{ 80, 443 }"
trusted_nets = "{ 10.0.0.0/8, 192.168.1.0/24 }"
Use macros in rules with $macro_name:
pass in on $ext_if proto tcp to port $web_ports
Tables
Tables are optimized data structures for holding large sets of IP addresses. They are faster than macros for address lookups and can be modified at runtime without reloading the entire ruleset.
table persist
table { 10.0.0.0/8, 172.16.0.0/12 }
table persist file "/etc/pf.blocklist"
The persist keyword keeps the table in memory even if no rules reference it, which is necessary for tables you populate dynamically via pfctl.
Options
Set global behavior at the top of the file:
set skip on lo0
set block-policy drop
set loginterface $ext_if
set optimization aggressive
- set skip on lo0 -- never filter loopback traffic. Critical for local services.
- set block-policy drop -- silently drop blocked packets (the alternative is return, which sends RST/ICMP).
- set loginterface -- collect per-interface byte and packet statistics.
Basic Ruleset: Default Deny
The foundation of any secure PF configuration is default deny inbound, allow outbound:
# Block everything by default
block in all
block return out all
# Allow all outbound traffic and keep state
pass out on $ext_if proto { tcp, udp, icmp } from ($ext_if) modulate state
# Allow SSH
pass in on $ext_if proto tcp to ($ext_if) port 22 modulate state
# Allow HTTP and HTTPS
pass in on $ext_if proto tcp to ($ext_if) port $web_ports modulate state
# Allow ICMP (ping)
pass in on $ext_if inet proto icmp icmp-type { echoreq, unreach }
The modulate state keyword randomizes TCP sequence numbers, adding a layer of protection against sequence prediction attacks. For UDP, use keep state instead.
Note the parentheses around ($ext_if) in address positions. This tells PF to dynamically resolve the interface address -- essential if your IP is assigned via DHCP.
NAT Configuration
If your FreeBSD machine acts as a gateway or router, you need NAT to let internal hosts reach the internet. On modern FreeBSD (using the OpenBSD 4.x+ PF syntax), NAT is configured inline with rules using nat-to.
Source NAT for a Gateway
pass out on $ext_if from $int_if:network to any nat-to ($ext_if)
This rewrites the source address of all outgoing packets from the internal network to the external interface address. The parentheses around ($ext_if) handle dynamic addresses.
For a static external IP, you can specify it directly:
pass out on $ext_if from $int_if:network to any nat-to 203.0.113.5
Bidirectional NAT (binat)
binat-to creates a one-to-one mapping between an internal and external address:
pass on $ext_if from 10.0.0.50 to any binat-to 203.0.113.10
Every connection from 10.0.0.50 appears as 203.0.113.10 externally, and inbound connections to 203.0.113.10 are forwarded to 10.0.0.50. This is useful for servers that need a dedicated public IP.
For more details on address translation, see our [NAT on FreeBSD](/blog/nat-freebsd/) guide.
Port Forwarding with rdr
To forward incoming connections on the external interface to an internal host, use rdr-to:
pass in on $ext_if proto tcp to ($ext_if) port 8080 rdr-to 10.0.0.50 port 80
This redirects TCP port 8080 on the firewall to port 80 on 10.0.0.50. You still need a corresponding pass rule for the traffic if your default policy blocks it. Combining pass with rdr-to in a single rule (as shown above) handles both in one line.
Forward a range of ports:
pass in on $ext_if proto tcp to ($ext_if) port 3000:3010 rdr-to 10.0.0.50
Tables for Dynamic Blocking
Tables combined with the overload mechanism give you brute-force protection without external tools. This is one of PF's best features.
Protecting SSH
table persist
block quick from
pass in on $ext_if proto tcp to ($ext_if) port 22 \
modulate state \
(max-src-conn 10, max-src-conn-rate 5/30, \
overload flush global)
This rule:
1. Allows SSH connections
2. Limits each source IP to 10 simultaneous connections
3. Limits each source IP to 5 new connections per 30 seconds
4. If either limit is exceeded, adds the offending IP to the table
5. flush global kills all existing states from that IP
The block quick rule above ensures that IPs in the bruteforce table are immediately dropped. The quick keyword makes PF stop processing further rules on a match -- it overrides the "last match wins" default.
Expiring Table Entries
PF does not expire table entries by itself. Use a cron job:
sh
# Remove entries older than 1 hour
pfctl -t bruteforce -T expire 3600
Add to /etc/crontab:
*/5 * * * * root /sbin/pfctl -t bruteforce -T expire 3600
Rate Limiting and Overload Tables
Beyond SSH protection, you can rate-limit any service. Here is a rule that limits HTTP connections:
table persist
pass in on $ext_if proto tcp to ($ext_if) port $web_ports \
modulate state \
(max-src-conn 100, max-src-conn-rate 30/5, \
overload flush global)
block quick from
For ICMP rate limiting, use a different approach since ICMP is connectionless:
pass in on $ext_if inet proto icmp icmp-type echoreq \
max-pkt-rate 50/10
This limits ping to 50 packets per 10 seconds across all sources.
Anchors for Modular Rulesets
Anchors let you load sub-rulesets dynamically without reloading the main pf.conf. This is invaluable for:
- Jail-specific firewall rules (see our [FreeBSD jails](/blog/freebsd-jails-guide/) guide)
- VPN rules loaded when a [WireGuard tunnel](/blog/wireguard-freebsd-setup/) comes up
- Fail2ban or CrowdSec integration
- Temporary rules for maintenance windows
Define an anchor in pf.conf:
anchor "jails/*"
anchor "wireguard"
Load rules into an anchor at runtime:
sh
echo "pass in on wg0 from 10.10.10.0/24 to any" | pfctl -a wireguard -f -
List rules in an anchor:
sh
pfctl -a wireguard -sr
Flush an anchor:
sh
pfctl -a wireguard -F rules
You can also load anchor rules from a file:
sh
pfctl -a jails/webserver -f /etc/pf.anchor.jails.webserver
This keeps your main pf.conf clean and allows individual services to manage their own firewall rules.
Logging with pflog
PF logs packets to the pflog0 interface in pcap format. Any rule with the log keyword sends matched packets to pflog.
Adding Log Keywords
Log all blocked packets:
block log in all
Log specific allowed traffic:
pass log in on $ext_if proto tcp to ($ext_if) port 22 modulate state
Reading Logs
Read the binary log file with tcpdump:
sh
tcpdump -n -e -ttt -r /var/log/pflog
Monitor in real time:
sh
tcpdump -n -e -ttt -i pflog0
Filter for a specific rule number (shown in pfctl -sr output):
sh
tcpdump -n -e -ttt -i pflog0 rulenum 5
The -e flag is important -- it shows the PF-specific metadata: rule number, action (pass/block), direction, and interface.
Log Rotation
Add to /etc/newsyslog.conf:
/var/log/pflog 600 7 * @T00 JB /var/run/pflogd.pid
This rotates the log daily, keeps 7 copies, and signals pflogd to reopen the file.
Managing PF with pfctl
pfctl is the command-line tool for controlling PF. Here is the complete reference for daily operations:
Loading and Flushing Rules
| Command | Description |
|---------|-------------|
| pfctl -f /etc/pf.conf | Load (or reload) the ruleset |
| pfctl -nf /etc/pf.conf | Parse and check syntax without loading |
| pfctl -F all | Flush all rules, NAT, tables, and states |
| pfctl -F rules | Flush filter rules only |
| pfctl -F states | Flush state table only |
Viewing State
| Command | Description |
|---------|-------------|
| pfctl -sr | Show loaded filter rules |
| pfctl -ss | Show current state table (active connections) |
| pfctl -si | Show filter statistics and counters |
| pfctl -sa | Show everything (rules, state, info, tables) |
| pfctl -sm | Show memory limits |
| pfctl -st | Show timeout values |
Table Management
| Command | Description |
|---------|-------------|
| pfctl -t bruteforce -T show | Show all IPs in the bruteforce table |
| pfctl -t bruteforce -T add 192.168.1.100 | Add an IP to the table |
| pfctl -t bruteforce -T delete 192.168.1.100 | Remove an IP from the table |
| pfctl -t bruteforce -T flush | Remove all entries from the table |
| pfctl -t bruteforce -T expire 3600 | Remove entries older than 3600 seconds |
| pfctl -t bruteforce -T test 192.168.1.100 | Check if an IP is in the table |
Enabling and Disabling
| Command | Description |
|---------|-------------|
| pfctl -e | Enable PF |
| pfctl -d | Disable PF |
Never disable PF on a production server without console access. If you need to temporarily open all traffic, load a permissive ruleset instead.
Complete Production Ruleset: Web Server
This ruleset is for a standalone FreeBSD web server running Nginx with SSH access. It includes brute-force protection, rate limiting, and logging.
# /etc/pf.conf -- Web Server
# FreeBSD 14.x
# --- Macros ---
ext_if = "vtnet0"
ssh_port = "22"
web_ports = "{ 80, 443 }"
icmp_types = "{ echoreq, unreach }"
# Trusted admin IPs (adjust to your network)
admin_nets = "{ 198.51.100.0/24 }"
# --- Tables ---
table persist
table persist
table persist file "/etc/pf.blocklist"
# --- Options ---
set skip on lo0
set block-policy drop
set loginterface $ext_if
set optimization normal
set state-policy if-bound
# --- Scrub ---
match in all scrub (no-df max-mss 1440)
# --- Default Deny ---
block log all
# --- Quick blocks ---
# Drop known bad actors
block drop quick from
block drop quick from
block drop quick from
# Block non-routable addresses on external interface
block drop in quick on $ext_if from { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4 }
# --- Outbound ---
pass out on $ext_if proto { tcp, udp } from ($ext_if) modulate state
pass out on $ext_if inet proto icmp from ($ext_if) icmp-type $icmp_types
# --- Inbound ---
# SSH with brute-force protection (restricted to admin IPs)
pass in log on $ext_if proto tcp from $admin_nets to ($ext_if) port $ssh_port \
modulate state \
(max-src-conn 10, max-src-conn-rate 3/30, \
overload flush global)
# HTTP/HTTPS with flood protection
pass in on $ext_if proto tcp to ($ext_if) port $web_ports \
modulate state \
(max-src-conn 100, max-src-conn-rate 30/5, \
overload flush global)
# Allow ICMP
pass in on $ext_if inet proto icmp icmp-type $icmp_types
# --- Anchors ---
anchor "custom/*"
Create the blocklist file:
sh
touch /etc/pf.blocklist
Validate and load:
sh
pfctl -nf /etc/pf.conf && pfctl -f /etc/pf.conf
Complete Gateway/Router Ruleset
This ruleset is for a FreeBSD machine with two interfaces acting as a network gateway. It provides NAT for internal hosts, port forwarding to an internal web server, and WireGuard VPN support.
First, enable IP forwarding in /etc/sysctl.conf:
net.inet.ip.forwarding=1
Apply immediately:
sh
sysctl net.inet.ip.forwarding=1
The gateway ruleset:
# /etc/pf.conf -- Gateway / Router
# FreeBSD 14.x
# --- Macros ---
ext_if = "igb0"
int_if = "igb1"
wg_if = "wg0"
int_net = "192.168.1.0/24"
wg_net = "10.10.10.0/24"
web_server = "192.168.1.50"
dns_servers = "{ 1.1.1.1, 9.9.9.9 }"
ssh_port = "22"
web_ports = "{ 80, 443 }"
icmp_types = "{ echoreq, unreach }"
# --- Tables ---
table persist
table persist file "/etc/pf.blocklist"
# --- Options ---
set skip on lo0
set block-policy drop
set loginterface $ext_if
set optimization normal
# --- Scrub ---
match in all scrub (no-df max-mss 1440)
# --- Default Deny ---
block log all
# --- Quick blocks ---
block drop quick from
block drop quick from
# Antispoofing
antispoof for $ext_if
antispoof for $int_if
# Block RFC 1918 on external interface
block drop in quick on $ext_if from { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }
# --- NAT ---
# Source NAT for internal network
pass out on $ext_if from $int_net to any nat-to ($ext_if)
# Source NAT for WireGuard clients
pass out on $ext_if from $wg_net to any nat-to ($ext_if)
# --- Port Forwarding ---
# Forward HTTP/HTTPS to internal web server
pass in on $ext_if proto tcp to ($ext_if) port $web_ports \
rdr-to $web_server modulate state
# --- Outbound ---
# Gateway itself
pass out on $ext_if proto { tcp, udp } from ($ext_if) modulate state
pass out on $ext_if inet proto icmp from ($ext_if) icmp-type $icmp_types
# Allow forwarded traffic from internal to external
pass out on $ext_if proto { tcp, udp, icmp } from { $int_net, $wg_net } modulate state
# --- Internal interface ---
# Allow all traffic from internal network
pass in on $int_if from $int_net to any
# Allow traffic to internal network (replies, forwarded traffic)
pass out on $int_if to $int_net
# --- WireGuard interface ---
pass in on $wg_if from $wg_net to any
pass out on $wg_if to $wg_net
# --- Inbound on external ---
# SSH to gateway with brute-force protection
pass in log on $ext_if proto tcp to ($ext_if) port $ssh_port \
modulate state \
(max-src-conn 5, max-src-conn-rate 3/60, \
overload flush global)
# WireGuard UDP
pass in on $ext_if proto udp to ($ext_if) port 51820
# ICMP
pass in on $ext_if inet proto icmp icmp-type $icmp_types
# --- Anchors ---
anchor "jails/*"
anchor "wireguard"
This gateway ruleset handles the most common scenarios: internal hosts accessing the internet via NAT, external users reaching an internal web server via port forwarding, and VPN clients connecting through WireGuard. See our [WireGuard VPN](/blog/wireguard-freebsd-setup/) guide for the tunnel configuration side.
Troubleshooting
PF Won't Start
Check syntax first:
sh
pfctl -nf /etc/pf.conf
If you see pfctl: /dev/pf: No such file or directory, PF is not loaded in the kernel. On FreeBSD 14 with GENERIC, this should not happen. On custom kernels, ensure you have:
device pf
device pflog
device pfsync
Locked Out via SSH
If you loaded a bad ruleset and lost SSH access:
1. Access the server via console (IPMI, serial, VNC through your hosting panel).
2. Disable PF: pfctl -d
3. Fix pf.conf.
4. Reload: pfctl -f /etc/pf.conf && pfctl -e
Prevention: always use pfctl -nf /etc/pf.conf before loading. Consider a cron job that disables PF after 5 minutes during testing:
sh
echo "pfctl -d" | at now + 5 minutes
If PF is not disabled by then, your rules work and you can cancel the job.
Traffic is Being Blocked but Rules Look Correct
Remember: last matching rule wins (unless quick is used). Check which rule is matching:
sh
pfctl -sr -v
The -v flag shows per-rule packet and byte counters. If a block rule near the bottom has a high counter, your pass rule higher up is being overridden.
State Table Issues
View active connections:
sh
pfctl -ss
If you see stale states after changing rules, flush them:
sh
pfctl -F states
Or kill states for a specific host:
sh
pfctl -k 192.168.1.100
NAT Not Working
Verify IP forwarding is enabled:
sh
sysctl net.inet.ip.forwarding
Must return 1. Check that your NAT rule uses the correct interfaces and ($ext_if) in parentheses for dynamic IPs.
Performance Tuning
For high-traffic servers, increase PF limits:
set limit { states 100000, frags 50000, src-nodes 50000, tables 10000, table-entries 500000 }
set timeout { tcp.established 3600, tcp.closing 60 }
Monitor current usage:
sh
pfctl -sm
pfctl -si
FAQ
Can I run PF alongside IPFW or IPFilter?
Technically PF and IPFW can coexist since they hook into different parts of the FreeBSD network stack, but running two firewalls simultaneously creates confusion and unpredictable behavior. Pick one. PF is the recommendation for new deployments.
Does PF work with IPv6?
Yes. Use inet6 instead of inet in your rules. For dual-stack, you can omit the address family entirely and the rule applies to both:
pass in on $ext_if proto tcp to port 443 modulate state
This matches both IPv4 and IPv6. To write IPv6-specific rules:
pass in on $ext_if inet6 proto tcp to port 443 modulate state
How do I block an entire country's IP range?
Load a country blocklist into a table. You can get CIDR lists from ipdeny.com or similar sources:
sh
fetch -o /etc/pf.blocklist.cn https://www.ipdeny.com/ipblocks/data/countries/cn.zone
pfctl -t blocklist -T replace -f /etc/pf.blocklist.cn
Define the table and block rule in pf.conf:
table persist file "/etc/pf.blocklist.cn"
block quick from
Automate updates with a weekly cron job.
How do I see which rule is blocking my traffic?
Enable logging on your block rules (block log all) and watch pflog:
sh
tcpdump -n -e -ttt -i pflog0
The output shows the rule number. Cross-reference with pfctl -sr -vn to see which rule that number corresponds to.
What is the difference between block drop and block return?
block drop silently discards the packet. The sender gets no response and eventually times out. This is the default when you set set block-policy drop.
block return sends a TCP RST for TCP packets and an ICMP port unreachable for UDP. This tells the sender immediately that the port is closed, which can speed up legitimate client timeouts but also confirms to attackers that a host exists at that IP.
For external interfaces, drop is generally preferred. For internal interfaces, return avoids timeout delays for legitimate users hitting wrong ports.
Can I use PF with FreeBSD jails?
Yes. PF runs on the host and filters traffic for all jails. Use anchors to manage per-jail rules:
anchor "jails/*"
Then load jail-specific rules:
sh
pfctl -a jails/webjail -f /etc/pf.anchor.webjail
This keeps jail firewall rules isolated and manageable. See our [FreeBSD jails](/blog/freebsd-jails-guide/) guide for the full setup.
How does PF handle FTP?
FTP is problematic for stateful firewalls because it opens dynamic data connections. FreeBSD includes ftp-proxy(8) to handle this. Add to pf.conf:
anchor "ftp-proxy/*"
pass in quick on $ext_if proto tcp to port 21 divert-to 127.0.0.1 port 8021
And enable the proxy in /etc/rc.conf:
ftpproxy_enable="YES"
However, if you control the server, switching to SFTP (which runs over SSH) eliminates the problem entirely.
Summary
PF gives you a firewall that is both powerful and readable. The combination of stateful filtering, tables, overload detection, and anchors covers everything from a single web server to a multi-segment network gateway -- all in a clean configuration language.
Key takeaways:
- Always start with set skip on lo0 and a default deny policy.
- Use pfctl -nf to validate before loading.
- Use tables and overload for brute-force protection instead of external tools.
- Use anchors to keep modular rulesets manageable.
- Log aggressively during setup, then pare back to blocked traffic only.
For the next steps in securing your FreeBSD system, see our guides on [FreeBSD hardening](/blog/hardening-freebsd-server/) and setting up a [WireGuard VPN](/blog/wireguard-freebsd-setup/).