How pf Handles Redirection vs NAT
pf separates two concerns that iptables conflates: address translation (nat-to) and port redirection (rdr-to). Both are translation rules, but they operate on different traffic directions. nat-to rewrites the source address on outbound packets, giving internal hosts internet access behind a single public IP. rdr-to rewrites the destination address on inbound packets, forwarding external traffic to an internal host or port.
A third rule, binat-to, handles bidirectional one-to-one NAT, mapping one external IP directly to one internal IP in both directions. This matters for services like SIP or FTP where the server needs to appear to have the real external IP, not a redirected port.
In pf, all translation rules are evaluated before filter rules. That ordering is non-negotiable. If you write a block rule before your rdr-to, the block still fires on the translated packet, not the original. The practical consequence: write your filter rules to match the post-translation addresses, not the pre-translation ones.
Enabling pf and IP Forwarding on FreeBSD
Before any NAT or redirection works, two things must be true: pf must be enabled and the kernel must forward packets between interfaces. On a fresh FreeBSD install, neither is guaranteed.
Add these lines to /etc/rc.conf:
pf_enable="YES"
pf_rules="/etc/pf.conf"
gateway_enable="YES"
Loading and Verifying the Configuration
After editing pf.conf, never reload blind. Check syntax first, then load, then verify active rules.
# Check syntax without loading
pfctl -nf /etc/pf.conf
# Load the ruleset
pfctl -f /etc/pf.conf
# Enable pf if not already running
pfctl -e
# Show active NAT rules
pfctl -s nat
# Show active redirection rules
pfctl -s rules
# Show translation state table
pfctl -s state
Basic NAT: Masquerade Internal Hosts
The most common setup is a FreeBSD box with one external interface (em0) and one internal interface (em1), masquerading a private subnet behind a single public IP. In pf, this is nat-to with the interface's address.
The exclamation mark in the nat-to rule excludes the external interface itself from being translated. Without it, traffic sourced from em0 would also get rewritten, which breaks routing.
# /etc/pf.conf - basic NAT
ext_if = "em0"
int_if = "em1"
int_net = "192.168.1.0/24"
# NAT outbound traffic from internal network
nat on $ext_if from $int_net to any -> ($ext_if)
# Allow forwarded traffic
pass in on $int_if from $int_net to any
pass out on $ext_if from $int_net to any
Port Redirection with rdr-to
rdr-to forwards inbound connections on a specific port to an internal host. The canonical use case is running a web server on 192.168.1.10 and making it reachable on the external IP's port 80.
The pass keyword at the start of the rdr-to rule makes it a combined match-and-pass rule. Without pass, you need a separate pass rule that matches the redirected traffic by its post-translation destination.
# Redirect external port 80 to internal web server
rdr pass on em0 proto tcp from any to (em0) port 80 -> 192.168.1.10 port 80
# Redirect external port 443 to same server
rdr pass on em0 proto tcp from any to (em0) port 443 -> 192.168.1.10 port 443
# Redirect SSH on non-standard external port to standard internal port
rdr pass on em0 proto tcp from any to (em0) port 2222 -> 192.168.1.20 port 22
Port Redirection Using match and rdr-to (Modern Syntax)
FreeBSD's pf (imported from OpenBSD) supports a cleaner match-based syntax alongside the legacy rdr keyword. The match rule performs the translation, and a separate pass rule allows the traffic. This split is more explicit and easier to audit in complex rulesets.
In our testing on FreeBSD 14.1, both syntaxes produce identical behavior. The match-based form is preferred in new configurations because it integrates cleanly with anchor-based rule organization.
# Modern syntax: match does translation, pass allows traffic
match in on em0 proto tcp from any to (em0) port 80 rdr-to 192.168.1.10 port 80
match in on em0 proto tcp from any to (em0) port 443 rdr-to 192.168.1.10 port 443
# Pass the redirected traffic (destination is now 192.168.1.10)
pass in on em0 proto tcp from any to 192.168.1.10 port { 80 443 }
Redirecting a Port Range
Redirecting a range of ports to the same host is common for passive FTP or game servers that use multiple consecutive ports. pf handles this with port ranges in the rdr-to rule. The translated port matches the offset within the range, so external port 30000 maps to internal 30000, 30001 to 30001, and so on.
# Redirect passive FTP data ports (30000-30100) to FTP server
rdr pass on em0 proto tcp from any to (em0) port 30000:30100 -> 192.168.1.30
# Redirect UDP game server ports
rdr pass on em0 proto udp from any to (em0) port 27015:27020 -> 192.168.1.40
Binat: One-to-One NAT
binat-to maps one external IP to one internal IP in both directions. This is the right tool when an internal server needs a dedicated external IP, not just a redirected port. SIP, FTPS with certificate validation, and some VPN endpoints require this.
For binat to work, the external IP (203.0.113.50 in this example) must be assigned to or aliased on em0. You can add an IP alias with ifconfig or by adding an entry to /etc/rc.conf.
# Add alias to external interface (also put in rc.conf for persistence)
ifconfig em0 alias 203.0.113.50/32
# /etc/pf.conf binat rule
# Traffic from 192.168.1.50 leaves as 203.0.113.50
# Traffic arriving at 203.0.113.50 goes to 192.168.1.50
binat on em0 from 192.168.1.50 to any -> 203.0.113.50
NAT Pools and Round-Robin
When you have a block of public IPs and want to distribute outbound connections across them, pf supports NAT pools with the round-robin modifier. This is common for SMTP servers avoiding IP reputation issues, or API clients needing source IP rotation.
The sticky-address modifier keeps a given internal IP mapped to the same external IP across connections. Without it, successive connections from the same host may go out through different external IPs, which breaks protocols that check source IP consistency.
# NAT pool across three public IPs
# 203.0.113.10 through 203.0.113.12 must be configured on em0
nat on em0 from 192.168.1.0/24 to any -> { 203.0.113.10 203.0.113.11 203.0.113.12 } round-robin
# With sticky-address: same internal IP always uses same external IP
nat on em0 from 192.168.1.0/24 to any -> { 203.0.113.10 203.0.113.11 203.0.113.12 } round-robin sticky-address
Hairpin NAT: Internal Clients Reaching Internal Servers by Public IP
Hairpin NAT (also called NAT reflection or loopback NAT) solves the case where internal clients try to reach an internal server using its public IP or external DNS name. Without it, the connection either fails or the server sees the client's real LAN IP but returns packets directly, bypassing the firewall and breaking the TCP handshake.
The fix requires two rules: an rdr-to that redirects the hairpin traffic, and a nat-to that masquerades the source so the server sends replies back through the firewall.
# Hairpin NAT: internal clients reach 192.168.1.10 via public IP 203.0.113.10
# Step 1: Redirect traffic arriving on internal interface destined for public IP
rdr on em1 proto tcp from 192.168.1.0/24 to 203.0.113.10 port 80 -> 192.168.1.10 port 80
# Step 2: NAT the source so the server replies back through the firewall
nat on em1 proto tcp from 192.168.1.0/24 to 192.168.1.10 port 80 -> 192.168.1.1
Using Anchors for Modular Redirection Rules
On servers running multiple services or environments, embedding all rdr-to rules directly in pf.conf becomes unmaintainable. pf anchors let you load sub-rulesets from separate files or from scripts. This is also the mechanism used by tools like pfSense, iocage, and custom deployment scripts.
For teams using automated provisioning - whether custom shell scripts or platforms like taskbotshub.ai for AI-driven DevOps pipelines - anchors are the integration point. A deployment job can write rules into an anchor file and reload just that anchor without touching the base ruleset.
# In /etc/pf.conf, declare the anchor
anchor "services/*"
load anchor "services" from "/etc/pf.anchors/services"
# /etc/pf.anchors/services (directory with per-service files)
# Each file can contain rdr-to and nat-to rules
# Example: /etc/pf.anchors/services/web
rdr pass on em0 proto tcp from any to (em0) port 80 -> 192.168.1.10 port 80
# Reload just the anchor without reloading base rules
pfctl -a services -f /etc/pf.anchors/services
Debugging NAT and Redirection
When redirections fail, the standard workflow is: check state table, watch pflog, and verify the routing table. pf logs to pflog0 by default if you have log enabled on rules. The pflog interface must be up for this to work.
The most common failure we see on fresh installs is missing the pass rule after a match-based rdr-to, or a block rule that matches post-translation addresses without accounting for the rewrite.
# Bring up pflog interface
ifconfig pflog0 create up
# Watch pf log in real time
tcpdump -n -e -ttt -i pflog0
# Check active NAT states for a specific host
pfctl -s state | grep 192.168.1.10
# Add log keyword to a rule for debugging
rdr log pass on em0 proto tcp from any to (em0) port 80 -> 192.168.1.10 port 80
# Check routing: verify packets can reach the internal host
route get 192.168.1.10
# Show interface statistics to catch packet drops
netstat -I em0 -b
Persisting pflog Across Reboots
pflog0 does not persist automatically. Add this to /etc/rc.conf to ensure it comes up at boot, and use syslogd or newsyslog to rotate logs written to /var/log/pflog.
# /etc/rc.conf
pflog_enable="YES"
pflog_logfile="/var/log/pflog"
# Read binary pflog file
tcpdump -n -e -r /var/log/pflog
# /etc/newsyslog.conf entry for rotation
/var/log/pflog 600 7 1000 * ZB /var/run/pflogd.pid
Complete Working pf.conf Example
This is the full pf.conf we use on our test server: one public interface (em0, IP 203.0.113.10), one internal interface (em1, subnet 192.168.1.0/24), with outbound NAT, web and SSH redirection, and hairpin NAT. Copy and adapt, change the interface names and IPs to match your hardware.
Interface names on FreeBSD depend on the driver. Intel NICs are em0/em1, virtio NICs in bhyve are vtnet0/vtnet1, and Broadcom NICs are bge0. Run ifconfig -a to confirm before editing pf.conf.
# /etc/pf.conf - tested on FreeBSD 14.1-RELEASE
ext_if = "em0"
int_if = "em1"
ext_ip = "203.0.113.10"
int_net = "192.168.1.0/24"
web_srv = "192.168.1.10"
ssh_srv = "192.168.1.20"
# Options
set block-policy drop
set skip on lo0
scrub in all
# NAT: masquerade internal hosts
nat on $ext_if from $int_net to any -> ($ext_if)
# Hairpin NAT
rdr on $int_if proto tcp from $int_net to $ext_ip port 80 -> $web_srv port 80
nat on $int_if proto tcp from $int_net to $web_srv port 80 -> 192.168.1.1
# Redirection: inbound to web server
rdr pass on $ext_if proto tcp from any to ($ext_if) port { 80 443 } -> $web_srv
# Redirection: inbound SSH on port 2222 to internal host
rdr pass on $ext_if proto tcp from any to ($ext_if) port 2222 -> $ssh_srv port 22
# Default block
block all
# Allow loopback
pass quick on lo0
# Allow established and related
pass out all keep state
# Allow inbound redirected traffic
pass in on $ext_if proto tcp from any to $web_srv port { 80 443 } keep state
pass in on $ext_if proto tcp from any to $ssh_srv port 22 keep state
# Allow internal network to forward
pass in on $int_if from $int_net keep state
Common Mistakes and How to Fix Them
Four errors account for most broken pf NAT setups in our experience.
First: forgetting gateway_enable="YES" in /etc/rc.conf. Without IP forwarding, the kernel drops packets between interfaces silently. Run sysctl net.inet.ip.forwarding to check - it should return 1.
Second: writing filter rules that match pre-translation addresses. After rdr-to rewrites the destination, your pass rules must match the internal IP, not the external one. If you block all and then try to pass traffic to (em0) port 80, the packet has already been rewritten to 192.168.1.10 by the time the filter sees it.
Third: using dynamic interface references like (em0) in nat-to rules when the external IP changes (DHCP). The parentheses tell pf to resolve the IP at rule evaluation time rather than load time - this is correct behavior and should be used whenever the interface uses DHCP.
Fourth: missing the no-nat exception for traffic that must not be translated. If you have a VPN tunnel on em0 and also run NAT, traffic destined for VPN peers will be incorrectly rewritten. Add a no-nat rule above the nat rule for the VPN subnet.
# Check IP forwarding
sysctl net.inet.ip.forwarding
# Enable without reboot
sysctl net.inet.ip.forwarding=1
# no-nat exception for VPN traffic
no nat on em0 from 192.168.1.0/24 to 10.8.0.0/24
nat on em0 from 192.168.1.0/24 to any -> (em0)