FreeBSD.software
Home/Blog/How to Set Up PF Firewall on FreeBSD
tutorial2026-03-29

How to Set Up PF Firewall on FreeBSD

Step-by-step guide to configuring PF firewall on FreeBSD. Covers pf.conf syntax, basic rules, NAT, port forwarding, tables, anchors, rate limiting, logging, and troubleshooting.

# 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/).