FreeBSD.software
Home/Blog/How to Configure NAT on FreeBSD with PF
tutorial2026-03-29

How to Configure NAT on FreeBSD with PF

Complete guide to NAT configuration on FreeBSD using PF firewall. Covers source NAT, port forwarding (rdr), 1:1 NAT (binat), NAT pools, logging, and troubleshooting.

# How to Configure NAT on FreeBSD with PF

Network Address Translation turns a FreeBSD box into a gateway. A single public IP can serve an entire private network. PF makes this straightforward -- its NAT syntax is clean, its state tracking is automatic, and the whole configuration lives in one file.

This guide covers every NAT scenario you will encounter: source NAT for outbound traffic, port forwarding for inbound services, 1:1 mapping for DMZ hosts, NAT pools for load distribution, and the static-port option for protocols that break under rewriting. Every example targets FreeBSD 14.x with PF, but the syntax works on 13.x as well.

If you have not set up PF yet, start with the [PF firewall guide](/blog/pf-firewall-freebsd/) and come back here once your base ruleset is working.

NAT Fundamentals

NAT rewrites IP addresses (and often ports) in packet headers as traffic passes through a gateway. The gateway maintains a state table so it can reverse the translation for return traffic. Three types cover nearly every use case.

Source NAT (SNAT)

Source NAT rewrites the source address of outbound packets. A host at 192.168.1.50 sends a packet to 8.8.8.8. The gateway rewrites the source to its own public IP, say 203.0.113.10, before forwarding the packet. When the reply comes back, the gateway checks its state table, rewrites the destination back to 192.168.1.50, and delivers it to the LAN.

This is what most people mean when they say "NAT." It is also called masquerading, PAT (Port Address Translation), or many-to-one NAT.

Destination NAT (DNAT / Port Forwarding)

Destination NAT rewrites the destination address of inbound packets. Traffic arriving at the gateway's public IP on port 443 gets redirected to an internal web server at 192.168.1.20:443. PF calls this rdr (redirect).

1:1 NAT (Bidirectional NAT)

Bidirectional NAT maps one public IP to one private IP, in both directions simultaneously. All traffic to 203.0.113.15 goes to 10.0.0.15, and all traffic from 10.0.0.15 appears to come from 203.0.113.15. PF calls this binat. It is common for DMZ servers that need a dedicated public address.

Enabling PF and IP Forwarding

Before any NAT rule works, two things must be in place: PF must be running, and the kernel must forward packets between interfaces.

Add the following to /etc/rc.conf:

sh

# Enable PF firewall

pf_enable="YES"

pf_rules="/etc/pf.conf"

pflog_enable="YES"

pflog_logfile="/var/log/pflog"

# Enable IP forwarding (required for NAT)

gateway_enable="YES"

The gateway_enable="YES" line sets net.inet.ip.forwarding=1 at boot. Without it, the kernel drops packets destined for other hosts instead of forwarding them through your NAT rules.

Apply immediately without rebooting:

sh

sysctl net.inet.ip.forwarding=1

service pf start

service pflog start

Verify forwarding is active:

sh

sysctl net.inet.ip.forwarding

# net.inet.ip.forwarding: 1

If you also need IPv6 NAT (rare, but it happens), add ipv6_gateway_enable="YES" to /etc/rc.conf as well.

Source NAT: Masquerading Outbound Traffic

Source NAT is the most common configuration. Every host on your LAN shares the gateway's public IP for outbound connections.

Basic Source NAT

Define your interfaces as macros at the top of /etc/pf.conf, then add the NAT rule:


# Macros

ext_if = "igb0"

int_if = "igb1"

lan_net = "192.168.1.0/24"

# NAT outbound traffic from LAN

match out on $ext_if from $lan_net to any nat-to ($ext_if)

The parentheses around ($ext_if) are important. They tell PF to use the current address assigned to that interface. If your public IP changes (DHCP from ISP), PF automatically picks up the new address. Without parentheses, PF resolves the address once at ruleset load time and never updates it.

How match Works

The match keyword applies the NAT translation but does not make a pass/block decision. The packet still needs a pass rule to actually leave the gateway. This separation is clean -- NAT rules handle translation, filter rules handle policy:


# NAT translation

match out on $ext_if from $lan_net to any nat-to ($ext_if)

# Filter rules

pass in on $int_if from $lan_net to any

pass out on $ext_if from any to any

You can also combine NAT and filtering in a single rule using pass instead of match:


pass out on $ext_if from $lan_net to any nat-to ($ext_if)

This does the translation and permits the packet in one statement. Use whichever style you prefer. The match approach scales better in complex rulesets because NAT and filtering remain independent.

NAT for Multiple Subnets

If you have multiple internal networks -- say a LAN and a separate [VLAN](/blog/freebsd-vlans/) -- NAT them all with one rule using a table or a macro list:


int_nets = "{ 192.168.1.0/24, 192.168.2.0/24, 10.0.0.0/24 }"

match out on $ext_if from $int_nets to any nat-to ($ext_if)

Each subnet gets its own NAT state entries, and PF handles port allocation automatically to avoid collisions.

Port Forwarding with rdr

Port forwarding (destination NAT) redirects inbound traffic to an internal host. This is how you expose services -- web servers, mail servers, game servers -- behind a NAT gateway.

Basic Port Forward

Forward incoming HTTPS traffic to an internal web server:


ext_if = "igb0"

web_server = "192.168.1.20"

match in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

Traffic arriving on the external interface at port 443 gets redirected to 192.168.1.20:443. The gateway tracks the state so reply packets are translated back automatically.

Forwarding to a Different Port

You can map external port 8080 to internal port 80:


match in on $ext_if proto tcp from any to ($ext_if) port 8080 rdr-to $web_server port 80

Multiple Port Forwards

Forward different services to different internal hosts:


web_server = "192.168.1.20"

mail_server = "192.168.1.25"

game_server = "192.168.1.30"

# Web traffic

match in on $ext_if proto tcp from any to ($ext_if) port { 80, 443 } rdr-to $web_server

# Mail traffic

match in on $ext_if proto tcp from any to ($ext_if) port { 25, 587, 993 } rdr-to $mail_server

# Game server (UDP)

match in on $ext_if proto udp from any to ($ext_if) port 27015 rdr-to $game_server

Port Forwarding with Filter Rules

The rdr-to rule translates the destination, but you still need a pass rule to admit the traffic. A complete setup looks like this:


# Translation

match in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

# Allow the redirected traffic through

pass in on $ext_if proto tcp from any to $web_server port 443

Note that the pass rule matches the translated destination ($web_server), not the original destination. Once rdr-to rewrites the packet, subsequent filter rules see the rewritten addresses.

1:1 NAT with binat

Bidirectional NAT maps a public IP directly to a private IP. All ports, all protocols, both directions. This is the right choice when an internal server needs its own public address.


ext_if = "igb0"

pub_ip = "203.0.113.15"

priv_ip = "10.0.0.15"

match on $ext_if from $priv_ip to any binat-to $pub_ip

With this rule:

- Outbound traffic from 10.0.0.15 appears to come from 203.0.113.15.

- Inbound traffic to 203.0.113.15 is delivered to 10.0.0.15.

You still need filter rules to control which ports are open:

# Allow web traffic to the binat host

pass in on $ext_if proto tcp from any to $priv_ip port { 80, 443 }

# Allow all outbound from the binat host

pass out on $ext_if from $priv_ip to any

The filter rules reference the private IP ($priv_ip) because PF evaluates filter rules after NAT translation in the inbound direction.

When to Use binat vs rdr

Use binat when:

- The internal host needs a dedicated public IP.

- You want all ports forwarded, not just specific ones.

- The host runs complex protocols that embed IP addresses in payloads (SIP, FTP active mode).

Use rdr when:

- You are forwarding specific ports from a shared public IP.

- Multiple internal hosts share one external address.

NAT Pools

When your gateway has multiple public IPs, you can distribute NAT across them. PF supports several pool types.

Round-Robin Pool

Distribute outbound NAT across multiple IPs in order:

ext_ips = "{ 203.0.113.10, 203.0.113.11, 203.0.113.12 }"

match out on $ext_if from $lan_net to any nat-to $ext_ips round-robin

Each new connection gets the next IP in the list. This spreads the per-IP connection count, which helps when upstream providers enforce per-IP rate limits.

Source-Hash Pool

Assign each internal host a consistent external IP based on a hash of its source address:


match out on $ext_if from $lan_net to any nat-to $ext_ips source-hash

Source-hash ensures that 192.168.1.50 always maps to the same public IP. This matters for services that tie sessions to source IPs.

Bitmask Pool

Bitmask maps the host portion of the internal address directly to the external pool:


match out on $ext_if from 10.0.0.0/24 to any nat-to 203.0.113.0/24 bitmask

10.0.0.15 maps to 203.0.113.15, 10.0.0.16 maps to 203.0.113.16, and so on. This requires the internal and external subnets to be the same size.

Sticky Address

Add sticky-address to any pool type to keep a host on the same external IP for all its connections during its session:


match out on $ext_if from $lan_net to any nat-to $ext_ips round-robin sticky-address

NAT with static-port

By default, PF rewrites the source port during NAT. Most protocols handle this fine, but some do not. SIP, IPsec (IKE), some online games, and active FTP can break when ports are rewritten.

The static-port option tells PF to preserve the original source port:


match out on $ext_if from $lan_net to any nat-to ($ext_if) static-port

**Warning:** static-port only works cleanly when each source port is used by one internal host at a time. If two LAN hosts both try to use source port 5060 (SIP), only one can be NATed. For this reason, apply static-port selectively rather than globally:


sip_phones = "{ 192.168.1.100, 192.168.1.101, 192.168.1.102 }"

# Static-port only for SIP traffic

match out on $ext_if proto udp from $sip_phones to any port 5060 nat-to ($ext_if) static-port

# Normal NAT for everything else

match out on $ext_if from $lan_net to any nat-to ($ext_if)

This approach is also useful for FTP clients and console gaming, where strict NAT detection depends on the source port remaining unchanged. If you are running a [WireGuard VPN](/blog/wireguard-freebsd-setup/) through NAT, static-port is generally not needed because WireGuard handles port changes natively.

Combining NAT with Filter Rules: match vs pass

PF gives you two strategies for combining NAT and filtering. Understanding when to use each one saves debugging time.

Strategy 1: Separate match and pass (Recommended)


# --- NAT rules ---

match out on $ext_if from $lan_net to any nat-to ($ext_if)

match in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

# --- Filter rules ---

block in all

pass in on $int_if from $lan_net to any

pass out on $ext_if from any to any

pass in on $ext_if proto tcp from any to $web_server port 443

NAT and filtering are decoupled. You can add or remove port forwards without touching filter rules, and vice versa. This is the approach used in most production deployments.

Strategy 2: Combined pass rules


pass out on $ext_if from $lan_net to any nat-to ($ext_if)

pass in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

Fewer lines, but tighter coupling. Changing a NAT translation means editing a filter rule. This works well for simple gateways with a small ruleset.

Rule Evaluation Order

PF processes a packet through these stages:

1. **Scrub / match rules** -- normalization and NAT translation.

2. **Filter rules** -- pass/block decisions, evaluated with translated addresses.

This means rdr-to rewrites happen before filter evaluation. If you redirect port 80 to 192.168.1.20, your pass rule must match destination 192.168.1.20, not the gateway's external IP. This catches people often.

Logging NAT Traffic

Logging is essential for debugging NAT issues and monitoring traffic patterns. PF logs packets through the pflog0 interface.

Log NAT Translations

Add the log keyword to any NAT rule:

match log out on $ext_if from $lan_net to any nat-to ($ext_if)

match log in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

Reading the Log

PF writes binary pcap data to /var/log/pflog. Use tcpdump to read it:

sh

# Read the log file

tcpdump -n -e -ttt -r /var/log/pflog

# Watch live traffic on pflog0

tcpdump -n -e -ttt -i pflog0

# Filter for NAT-related log entries

tcpdump -n -e -ttt -i pflog0 host 192.168.1.20

The -e flag shows the PF rule number and action, which tells you exactly which rule matched.

Log Only Specific Traffic

Logging everything generates noise. Log only what you need:


# Log only port forwards (useful for debugging)

match log in on $ext_if proto tcp from any to ($ext_if) port 443 rdr-to $web_server port 443

# Log NAT only for a specific host (debugging one machine)

match log out on $ext_if from 192.168.1.50 to any nat-to ($ext_if)

For long-term log management, configure newsyslog to rotate /var/log/pflog. PF writes to this file continuously and it will grow without rotation.

Viewing the NAT State Table

PF maintains a state table for all active connections. The state table is your primary debugging tool for NAT.

View All States

sh

pfctl -ss

Output shows each tracked connection with source, destination, and translated addresses:


all tcp 203.0.113.10:54321 -> 8.8.8.8:443 ESTABLISHED:ESTABLISHED

[192.168.1.50:12345]

This tells you that internal host 192.168.1.50:12345 was NATed to 203.0.113.10:54321 to reach 8.8.8.8:443.

View NAT Rules

sh

pfctl -sn

This displays the active NAT rules (translations only, not filter rules). Use it to confirm your NAT rules loaded correctly.

View Everything

sh

# All rules including NAT

pfctl -sa

# State table statistics

pfctl -si

# State table with verbose details

pfctl -ss -v

Clear State Table

If you change NAT rules and need to force connections to re-establish with the new translation:

sh

# Reload rules (flushes states by default)

pfctl -f /etc/pf.conf

# Manually flush all states

pfctl -Fs

# Flush states for a specific host

pfctl -k 192.168.1.50

Flushing states kills active connections. On a production gateway, this means every TCP session through the NAT drops and must be re-established. Time this carefully.

Complete Gateway/Router NAT Example

Here is a full /etc/pf.conf for a FreeBSD gateway with two interfaces, serving a LAN with NAT, port forwarding, and sensible security defaults. For the complete router setup including DHCP and DNS, see the [FreeBSD router setup](/blog/freebsd-router-gateway/) guide.


# =====================================================

# /etc/pf.conf -- FreeBSD NAT Gateway

# =====================================================

# --- Macros ---

ext_if = "igb0" # WAN interface

int_if = "igb1" # LAN interface

lan_net = "192.168.1.0/24"

# Internal servers

web_server = "192.168.1.20"

mail_server = "192.168.1.25"

# Service ports

web_ports = "{ 80, 443 }"

mail_ports = "{ 25, 587, 993 }"

# --- Tables ---

table persist

# --- 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 1460)

# --- NAT Rules ---

# Source NAT for LAN

match out on $ext_if from $lan_net to any nat-to ($ext_if)

# Port forwarding

match in on $ext_if proto tcp from any to ($ext_if) port $web_ports rdr-to $web_server

match in on $ext_if proto tcp from any to ($ext_if) port $mail_ports rdr-to $mail_server

# --- Filter Rules ---

# Default deny

block log all

# Allow loopback

pass quick on lo0 all

# Allow all outbound from gateway itself

pass out on $ext_if proto { tcp, udp, icmp } from ($ext_if) to any

# Allow LAN to go anywhere (NAT handles translation)

pass in on $int_if from $lan_net to any

pass out on $ext_if from $lan_net to any

# Allow LAN to reach gateway for DNS, DHCP

pass in on $int_if proto { tcp, udp } from $lan_net to ($int_if) port { 53, 67 }

# Allow forwarded web traffic

pass in on $ext_if proto tcp from any to $web_server port $web_ports

# Allow forwarded mail traffic

pass in on $ext_if proto tcp from any to $mail_server port $mail_ports

# SSH to gateway with brute-force protection

pass in on $ext_if proto tcp from any to ($ext_if) port 22 \

keep state (max-src-conn 5, max-src-conn-rate 3/30, \

overload flush global)

block in quick from

# Allow ICMP (ping) from outside -- useful for monitoring

pass in on $ext_if inet proto icmp icmp-type { echoreq, unreach }

# Allow all outbound from LAN hosts (return traffic handled by state)

pass out on $int_if from any to $lan_net

Loading and Testing

sh

# Check syntax before loading

pfctl -nf /etc/pf.conf

# Load the ruleset

pfctl -f /etc/pf.conf

# Verify NAT rules loaded

pfctl -sn

# Verify filter rules loaded

pfctl -sr

# Test from a LAN host

ping 8.8.8.8

curl -I https://freebsd.org

Always run pfctl -nf first. A syntax error in pf.conf can flush your running ruleset and lock you out.

Troubleshooting NAT

No Internet from LAN Hosts

This is the most common NAT issue. Work through this checklist:

**1. Is IP forwarding enabled?**

sh

sysctl net.inet.ip.forwarding

Must return 1. If it returns 0, set it: sysctl net.inet.ip.forwarding=1 and add gateway_enable="YES" to /etc/rc.conf.

**2. Is PF running?**

sh

pfctl -si | head -5

Look for Status: Enabled. If disabled, run pfctl -e.

**3. Are NAT rules loaded?**

sh

pfctl -sn

If empty, your rules did not load. Check for syntax errors with pfctl -nf /etc/pf.conf.

**4. Are filter rules blocking the traffic?**

sh

# Watch blocked packets in real time

tcpdump -n -e -ttt -i pflog0

If you see block entries for LAN traffic, your filter rules are too restrictive. Make sure you have a pass rule for outbound LAN traffic on both the internal and external interfaces.

**5. Is the LAN host configured correctly?**

The LAN host must have:

- An IP in the NAT subnet (e.g., 192.168.1.x/24).

- Gateway set to the FreeBSD box's internal IP (e.g., 192.168.1.1).

- DNS servers configured (e.g., 8.8.8.8 or the gateway if it runs a resolver).

Port Forward Not Working

**1. Check the rdr rule loaded:**

sh

pfctl -sn | grep rdr

**2. Check the pass rule matches the translated address:**

The filter rule must match the internal server IP, not the external IP. This is the most common port-forwarding mistake.

Wrong:


pass in on $ext_if proto tcp from any to ($ext_if) port 443

Right:


pass in on $ext_if proto tcp from any to $web_server port 443

**3. Verify the internal server is listening:**

sh

sockstat -l -4 | grep 443

Run this on the internal server, not the gateway.

**4. Check for state conflicts:**

If you changed the rdr target and reloaded rules, old states may still point to the previous destination. Flush states:

sh

pfctl -Fs

**5. Test from outside the network:**

Port forwards are only active on the external interface. Testing from a LAN host to the gateway's external IP does not trigger the rdr rule. You need to test from the internet or use a NAT reflection rule (also called hairpin NAT):


# Hairpin NAT -- allows LAN hosts to reach port forwards via the external IP

pass in on $int_if proto tcp from $lan_net to ($ext_if) port 443 rdr-to $web_server port 443

match out on $int_if proto tcp from $lan_net to $web_server port 443 nat-to ($int_if)

FTP Through NAT

Active FTP breaks behind NAT because the server opens a connection back to the client on a random port. Solutions:

**1. Use passive FTP.** Configure your FTP client to use PASV mode. This is the simplest fix.

**2. Use ftp-proxy.** FreeBSD includes ftp-proxy(8), which intercepts FTP connections and rewrites the data channel negotiation:

sh

# /etc/rc.conf

ftpproxy_enable="YES"

Add to pf.conf:


# Anchor for ftp-proxy

anchor "ftp-proxy/*"

# Redirect outbound FTP to the proxy

pass in on $int_if proto tcp from $lan_net to any port 21 rdr-to 127.0.0.1 port 8021

**3. Use static-port for specific FTP clients.** This preserves the source port, which helps in some active FTP scenarios, but it is less reliable than ftp-proxy.

High State Table Usage

Each NAT connection creates a state entry. The default state table limit is 200,000 entries on FreeBSD. Check usage:

sh

pfctl -si | grep states

If you are hitting the limit, increase it:


# In pf.conf

set limit states 500000

Also check for state leaks -- connections that are not closing properly. Look for a large number of states from a single internal host:

sh

pfctl -ss | awk '{print $3}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

FAQ

What is the difference between nat-to and rdr-to in PF?

nat-to rewrites the source address of outbound packets (source NAT). It makes internal hosts appear to use the gateway's public IP. rdr-to rewrites the destination address of inbound packets (destination NAT / port forwarding). It redirects traffic arriving at the gateway to an internal host. They solve opposite problems: nat-to gets LAN hosts to the internet, rdr-to gets the internet to LAN hosts.

Do I need separate pass rules when using match for NAT?

Yes. A match rule only applies the address translation -- it does not permit or deny the packet. You need explicit pass rules to allow the translated traffic through. If you use pass ... nat-to or pass ... rdr-to instead of match, the pass and translation happen in one rule, but most production configs keep them separate for clarity.

Can I NAT IPv6 traffic with PF on FreeBSD?

PF on FreeBSD supports nat-to and rdr-to for IPv6, but IPv6 NAT is strongly discouraged. The entire point of IPv6 is end-to-end addressing without translation. If you need IPv6 NAT for a specific reason (renumbering, multihoming without PI space), the syntax is the same -- just use inet6 addresses. In practice, use pass rules with IPv6 and save NAT for IPv4.

How do I NAT traffic from a WireGuard or VPN tunnel?

Add the VPN subnet to your NAT rule. If your [WireGuard tunnel](/blog/wireguard-freebsd-setup/) uses 10.10.10.0/24:


vpn_net = "10.10.10.0/24"

all_internal = "{ 192.168.1.0/24, 10.10.10.0/24 }"

match out on $ext_if from $all_internal to any nat-to ($ext_if)

This gives VPN clients internet access through the gateway's public IP, just like LAN clients.

Why do my NAT states show the wrong external IP after a DHCP renewal?

If your ISP assigns IPs via DHCP and the address changes, PF needs to pick up the new address. Ensure you use parentheses around the interface name in your NAT rules: nat-to ($ext_if) not nat-to $ext_if. With parentheses, PF dynamically resolves the current interface address. Without them, PF uses whatever address was assigned when the ruleset was loaded. After a DHCP renewal, reload rules with pfctl -f /etc/pf.conf to flush old states that reference the previous IP.

How many NAT connections can PF handle?

PF's default state table holds 200,000 entries. Each NATed connection uses one state. A typical home or small office gateway rarely exceeds a few thousand. A busy gateway serving hundreds of users can hit the limit. Increase it with set limit states 500000 or higher. FreeBSD itself can handle millions of states on modern hardware -- the bottleneck is usually RAM (each state consumes roughly 1 KB).

Can I use NAT and VLAN tagging together?

Yes. Define your VLAN interfaces and use them in NAT rules just like physical interfaces. If igb0.10 is your VLAN 10 interface carrying the 192.168.10.0/24 subnet, NAT it the same way:


match out on $ext_if from 192.168.10.0/24 to any nat-to ($ext_if)

See the [FreeBSD VLANs guide](/blog/freebsd-vlans/) for setting up VLAN interfaces.

Summary

NAT on FreeBSD with PF comes down to three directives: nat-to for source NAT, rdr-to for port forwarding, and binat-to for 1:1 mapping. The match keyword applies translation without filtering, keeping your ruleset modular. Pool options (round-robin, source-hash, bitmask) distribute load across multiple public IPs, and static-port handles protocols that cannot tolerate port rewriting.

The debugging workflow is consistent: check pfctl -sn for loaded NAT rules, pfctl -ss for active states, and tcpdump -i pflog0 for logged packets. Almost every NAT problem traces back to missing IP forwarding, a filter rule that references the pre-translation address instead of the post-translation address, or stale states from a previous configuration.

Start with the complete gateway example, test each piece, and expand from there. NAT is one of those things that either works completely or fails completely -- there is very little middle ground, which makes systematic debugging straightforward.