Prerequisites and Interface Setup
Before touching pf.conf, confirm your interface names and routing table. On modern FreeBSD with Intel NICs you will see em0, em1; with virtio you get vtnet0, vtnet1. The examples below use em0 (WAN) and em1 (LAN).
Check your current interfaces and addresses:
Enable IP forwarding permanently in /etc/sysctl.conf. Without this, pf will NAT packets but the kernel drops them before they ever reach the wire.
Also add the gateway_enable flag in /etc/rc.conf so forwarding survives reboots. Set it alongside the pf activation lines.
ifconfig -a | grep -E '^(em|vtnet|igb|ix)'
sysctl net.inet.ip.forwarding
# If it returns 0, set it now:
sysctl net.inet.ip.forwarding=1
# /etc/sysctl.conf
net.inet.ip.forwarding=1
# /etc/rc.conf additions
gateway_enable="YES"
pf_enable="YES"
pf_rules="/etc/pf.conf"
pflog_enable="YES"
Enabling pf and Loading the Module
FreeBSD does not load pf by default. You can load it immediately with kldload, or let rc.d handle it on next boot. For a running system, do both.
After kldload, verify the module is present. The pf kernel module exposes /dev/pf once loaded. Without /dev/pf, pfctl returns 'no such file or directory' and nothing works.
pflog0 is the logging interface. Enable pflogd to capture pf log data to /var/log/pflog, which you can read with tcpdump using the -e flag.
# Load pf immediately
kldload pf
kldload pflog
# Verify
kldstat | grep pf
ls /dev/pf
# Start services now without rebooting
service pf start
service pflog start
# Confirm pf is running
pfctl -s info | head -5
Basic pf.conf Structure for NAT
pf.conf has a strict ordering requirement: macros, then tables, then options, then normalization (scrub), then translation (nat/rdr/binat), then filtering rules. Putting a nat rule after a pass rule is not a syntax error but it will not work as expected in older pf versions. FreeBSD 14 pf uses the post-OpenBSD 4.7 rule syntax where nat-to and rdr-to are modifiers on pass rules, not standalone rule types. Both syntaxes coexist, but the newer form is cleaner and what we use here.
Define your interfaces and networks as macros at the top. This makes bulk edits one-line changes. The ext_if macro is the single most important variable in the file.
# /etc/pf.conf - baseline NAT gateway
# Macros
ext_if = "em0"
int_if = "em1"
lan_net = "192.168.10.0/24"
# Tables
table persist
# Options
set block-policy drop
set loginterface em0
set skip on lo0
# Normalization
scrub in all
# NAT - outbound masquerade
pass out on $ext_if inet from $lan_net to any nat-to ($ext_if)
# Filtering
block in all
block out all
pass in on $int_if from $lan_net to any keep state
pass out on $ext_if from any to any keep state
pass in on $ext_if proto tcp to ($ext_if) port 22 keep state
How nat-to Works in FreeBSD 14 pf
The nat-to modifier rewrites the source address of matching packets to the address of the specified interface. Parentheses around the interface name, as in ($ext_if), tell pf to re-evaluate the address dynamically. This matters on DHCP WAN connections where em0's IP changes. Without parentheses pf caches the address at ruleset load time and NAT breaks after a DHCP renewal.
The nat-to rule must match on the outbound interface. A common mistake is applying it on the inbound interface. Traffic from 192.168.10.0/24 exits on em0, so the rule is 'pass out on em0'.
State tracking is implicit on pass rules but you can be explicit with 'keep state'. For UDP-heavy workloads like DNS or gaming, add 'keep state (max-src-states 100)' to cap per-source state table entries and prevent state table exhaustion.
Check how many NAT translations are active:
# Show active state table entries with NAT translations
pfctl -s states | grep '192.168.10'
# Count total states
pfctl -s states | wc -l
# Show NAT-specific info
pfctl -s nat
# Watch state table in real time (refresh every 1s)
watch -n 1 'pfctl -s states | wc -l'
Port Forwarding with rdr-to
Port forwarding redirects inbound traffic on the WAN interface to an internal host. In FreeBSD 14 pf, this is rdr-to on an inbound pass rule. The classic use case: expose a web server at 192.168.10.50 on the internet without giving it a public IP.
The rule must appear before the block-all rule but the ordering relative to other pass rules matters only when rules overlap. pf uses first-match for translation rules.
For TCP services you almost always want to redirect specific ports. For a service like a game server that uses a UDP port range, use the port range syntax shown below.
After adding rdr-to rules, reload the ruleset and verify with pfctl -s rules. Test from an external host using nc or curl. If the connection reaches the internal server but the server's reply goes out unNATted, you have an asymmetric routing problem - check that the internal server's default gateway points back through the FreeBSD NAT host.
# /etc/pf.conf - port forwarding additions
# Forward TCP 80 and 443 to internal web server
pass in on $ext_if proto tcp to ($ext_if) port { 80, 443 } rdr-to 192.168.10.50 keep state
# Forward UDP game server ports 27015-27030
pass in on $ext_if proto udp to ($ext_if) port 27015:27030 rdr-to 192.168.10.60 keep state
# SSH on non-standard external port 2222 -> internal port 22
pass in on $ext_if proto tcp to ($ext_if) port 2222 rdr-to 192.168.10.50 port 22 keep state
# Reload ruleset
pfctl -f /etc/pf.conf
# Verify rules loaded
pfctl -s rules
pfctl -s nat
Bidirectional NAT with binat-to
binat-to maps a single internal IP to a single external IP in both directions. This is the right tool when an internal server needs a stable public IP for outbound connections - common for mail servers that need consistent reverse DNS, or for services where the source IP matters for ACLs at remote endpoints.
binat-to requires a static external IP. On DHCP connections it is unusable. The external IP must be configured on em0 as an alias, not just as the primary address, unless you own a /30 or larger block and want to use a secondary address for the binat mapping.
The binat-to rule handles both directions: outbound from 192.168.10.80 appears as 203.0.113.10, and inbound to 203.0.113.10 is forwarded to 192.168.10.80. You do not need a separate rdr-to rule for the inbound direction.
# Add IP alias to WAN interface
ifconfig em0 alias 203.0.113.10/32
# Persist it in /etc/rc.conf
ifconfig_em0_alias0="inet 203.0.113.10/32"
# /etc/pf.conf - binat rule
# Must appear before the general nat-to rule
pass on $ext_if inet from 192.168.10.80 to any binat-to 203.0.113.10
# Test outbound source IP from 192.168.10.80
# (run from the internal host)
curl -s https://api.ipify.org
# Should return 203.0.113.10
The rdr-anchor Pattern for Local Redirects
When you run a local DNS resolver like unbound and want internal clients to use it transparently, you need to intercept DNS queries destined for external resolvers and redirect them to 127.0.0.1:53. This requires an rdr-anchor because pf does not redirect traffic that originates on the local machine without one.
Anchors are sub-rulesets you can load and flush independently. The rdr-anchor goes in the translation section of pf.conf. You then load rules into it with pfctl -a or from a file.
This same pattern works for relayd, squid transparent proxy, and any other local service that needs to intercept traffic. The anchor name is arbitrary but use something descriptive.
# /etc/pf.conf - add anchor declarations
rdr-anchor "relayd/*"
rdr-anchor "dns_intercept"
# Load DNS intercept rules into the anchor
# Create the anchor rule file
cat > /etc/pf.anchors/dns_intercept << 'EOF'
pass in on em1 proto { tcp, udp } from 192.168.10.0/24 to !192.168.10.1 port 53 rdr-to 127.0.0.1 port 53
EOF
# Load it
pfctl -a dns_intercept -f /etc/pf.anchors/dns_intercept
# Verify the anchor has rules
pfctl -a dns_intercept -s rules
# To make it persistent, reference the file from pf.conf
# Add inside /etc/pf.conf:
anchor "dns_intercept"
load anchor "dns_intercept" from "/etc/pf.anchors/dns_intercept"
NAT with Multiple WAN IPs and Round Robin
If your host has multiple public IPs, you can distribute outbound NAT across them using a pool or round-robin. FreeBSD pf supports four pool types: none, bitmask, random, and round-robin. For multiple external IPs used as a NAT pool, round-robin distributes connections across addresses sequentially.
This is useful when you have a /29 or larger block and want to spread outbound source IPs to avoid rate limiting from external APIs or mail blacklisting.
Define the pool with a table and reference it in nat-to. Note that round-robin with a port hash gives better distribution for many short-lived connections from few internal hosts.
# /etc/pf.conf - multiple WAN IPs for NAT pool
table { 203.0.113.1, 203.0.113.2, 203.0.113.3 }
# Round-robin outbound NAT across the pool
pass out on $ext_if inet from $lan_net to any \
nat-to round-robin
# Sticky address keeps sessions from the same source
# on the same external IP (important for stateful protocols)
pass out on $ext_if inet from $lan_net to any \
nat-to round-robin sticky-address
# Verify which external IP a source uses
pfctl -s states | grep '192.168.10.20'
Logging NAT Events and Debugging
pf logs matched rules that include the 'log' keyword to pflog0. tcpdump on pflog0 shows pre-NAT and post-NAT addresses with the right flags. For NAT debugging, logging on the translation rule and the corresponding filter rule together gives you the full picture.
The -e flag to tcpdump on pflog0 prints pf metadata including rule number, action, and interface. Pipe through head or match on a specific host to avoid flooding your terminal on a busy gateway.
For persistent logging, pflogd writes to /var/log/pflog. Read it offline with tcpdump -r. On our test server we rotate pflog daily with newsyslog and keep 7 days of binary captures for post-incident analysis.
Rule statistics show hit counts per rule, which tells you whether a NAT rule is matching at all. Zero hits on a nat-to rule while traffic is flowing means the traffic is not matching the rule's criteria.
# Add log keyword to rules you want to trace
pass out on $ext_if inet from $lan_net to any nat-to ($ext_if) log
# Live capture on pflog0
tcpdump -n -e -i pflog0
# Filter to one internal host
tcpdump -n -e -i pflog0 host 192.168.10.20
# Read saved pflog file
tcpdump -n -e -r /var/log/pflog | grep '192.168.10'
# Show per-rule statistics (hit counts)
pfctl -v -s rules
# Show state table for one IP
pfctl -s states | grep '192.168.10.20'
# Test NAT from an internal host
# (on 192.168.10.20)
curl -s https://api.ipify.org
# Should return em0's public IP
Handling NAT Hairpinning
NAT hairpinning - also called NAT loopback - lets internal hosts reach internal servers using the server's public IP. Without it, an internal client connecting to 203.0.113.10 (which rdrs to 192.168.10.50) gets no response because the return traffic goes directly host-to-host without passing through pf.
The fix is a separate rdr-to rule on the internal interface that catches traffic from the LAN destined for the public IP, plus a nat-to rule that rewrites the source so replies come back through the gateway.
On our test server, hairpinning adds roughly 0.3ms of latency compared to direct LAN-to-LAN connections because every packet passes through pf state tracking twice. For most applications this is irrelevant. For latency-sensitive internal services, use split-horizon DNS instead - serve the private IP for internal queries and the public IP for external queries. This is the better solution when you control DNS, which you should if you are running a FreeBSD gateway. If you are automating DNS zone management across environments, tools like taskbotshub.ai can help orchestrate DNS record updates as part of a deployment pipeline.
# /etc/pf.conf - NAT hairpinning
# Traffic from LAN to public IP of internal server
pass in on $int_if proto tcp from $lan_net to 203.0.113.10 port { 80, 443 } \
rdr-to 192.168.10.50
# Re-NAT the source so replies return through gateway
pass out on $int_if proto tcp from $lan_net to 192.168.10.50 port { 80, 443 } \
nat-to ($int_if)
# Test from internal host (192.168.10.20)
curl -s http://203.0.113.10/
# Should reach 192.168.10.50
Complete Production pf.conf for a NAT Gateway
Below is a complete, annotated pf.conf suitable for a small-to-medium office gateway or a VM host acting as a NAT router. It incorporates all the patterns above, adds state limits to prevent table exhaustion, and blocks common attack vectors on the WAN interface.
After loading this configuration, run 'pfctl -f /etc/pf.conf' and check 'pfctl -s info' to confirm the ruleset loaded cleanly with zero errors. The 'Syntax OK' output from 'pfctl -n -f /etc/pf.conf' (dry run) should always be your first check before loading.
For projects where you need to document or expose this gateway's public endpoint, having a clean domain name matters more than most people admit. If you are spinning up a new lab environment or client-facing service, registering a clear, memorable hostname through a service like nicename.me avoids the 'what is our gateway's hostname again' problem during incidents.
# /etc/pf.conf - production NAT gateway
# FreeBSD 14.1 - tested 2026-08
# --- Macros ---
ext_if = "em0"
int_if = "em1"
lan_net = "192.168.10.0/24"
gw_ip = "192.168.10.1"
admin_nets = "{ 192.168.10.0/24 }"
# --- Tables ---
table persist
table persist file "/etc/pf.bogons"
# --- Options ---
set block-policy drop
set loginterface $ext_if
set skip on lo0
set state-policy if-bound
set limit states 200000
set limit frags 50000
# --- Normalization ---
scrub in all random-id min-ttl 15
scrub out all
# --- Translation ---
# binat for mail server (must precede general nat-to)
pass on $ext_if inet from 192.168.10.80 to any binat-to 203.0.113.10
# Port forwards
pass in on $ext_if proto tcp to ($ext_if) port { 80, 443 } \
rdr-to 192.168.10.50 keep state
pass in on $ext_if proto tcp to ($ext_if) port 2222 \
rdr-to 192.168.10.50 port 22 keep state
# Hairpin
pass in on $int_if proto tcp from $lan_net to 203.0.113.50 port { 80, 443 } \
rdr-to 192.168.10.50
pass out on $int_if proto tcp from $lan_net to 192.168.10.50 port { 80, 443 } \
nat-to ($int_if)
# General outbound NAT
pass out on $ext_if inet from $lan_net to any nat-to ($ext_if) static-port
# Anchors
anchor "relayd/*"
anchor "dns_intercept"
load anchor "dns_intercept" from "/etc/pf.anchors/dns_intercept"
# --- Filter ---
block in all
block out all
block in quick on $ext_if from
block in quick on $ext_if from
# Brute-force protection on SSH
pass in on $ext_if proto tcp to ($ext_if) port 22 keep state \
(max-src-conn 5, max-src-conn-rate 3/10, \
overload flush global)
# Allow established outbound from WAN
pass out on $ext_if proto { tcp, udp, icmp } keep state
# Allow all from LAN
pass in on $int_if from $lan_net to any keep state
pass out on $int_if to $lan_net keep state
# ICMP
pass in inet proto icmp all icmp-type { echoreq, unreach, timex }
pass out inet proto icmp all
Troubleshooting NAT Failures
The three most common NAT failures on FreeBSD are: forwarding disabled, rule ordering problems, and state table exhaustion.
Forwarding: 'sysctl net.inet.ip.forwarding' returning 0 is the most common cause of 'NAT is configured but nothing works'. Fix it immediately with sysctl and add it to /etc/sysctl.conf.
Rule ordering: Run 'pfctl -v -s rules' and look at the order. Translation rules (nat-to, rdr-to) in the old syntax must precede filter rules. In the new modifier syntax they are attached to pass rules, so ordering among pass rules matters. Use 'pfctl -s states' to confirm state table entries are being created for your traffic.
State table exhaustion: 'pfctl -s info | grep States' shows current and limit. If current approaches the limit, connections silently fail. Increase the limit with 'set limit states' or tune timeouts to expire idle states faster.
Asymmetric routing: If pf sees only one direction of a TCP flow it will block it with 'state violation'. This happens when traffic enters em0 but exits through a second WAN interface not tracked by pf. Solve it with 'set state-policy if-bound' or route all traffic through a single interface.
# Diagnostic sequence for broken NAT
# 1. Check forwarding
sysctl net.inet.ip.forwarding
# 2. Check pf is running
pfctl -s info | grep -E '^Status'
# 3. Dry-run config check
pfctl -n -f /etc/pf.conf
# 4. Count states
pfctl -s info | grep States
# 5. Watch a specific host's traffic
tcpdump -n -i em1 host 192.168.10.20 &
tcpdump -n -i em0 host 192.168.10.20 &
# If you see packets on em1 but not em0, NAT is not firing
# 6. Check rule hit counts
pfctl -v -s rules | grep -A2 'nat-to'
# 7. Flush bruteforce table if locked out
pfctl -t bruteforce -T flush
# 8. Check bogons file exists
ls -la /etc/pf.bogons
# If missing, comment out the bogons table or fetch it:
fetch -o /etc/pf.bogons https://nerd.dk/nerd/bogons/nerd-bogons.txt