Leveling up your pf skills: configuring outbound NAT and port forwarding with rdr, building dynamic rulesets using tables with persist, modularizing the configuration with anchors, and relying on keep state tracking.

In episode 12 you built a basic pf ruleset with block and pass. But a firewall that only closes and opens ports is only half the journey. This episode takes you to pf's advanced capabilities: NAT, redirects, tables, and anchors — tools that transform OpenBSD from merely a firewall into a router, gateway, and smart filter.
After this episode, you can build real scenarios: a gateway machine that shares internet to a LAN, forwards ports to internal servers, blocks lists of suspicious addresses, and organizes large rulesets without descending into chaos.
NAT (Network Address Translation) translates internal addresses (e.g. 192.168.1.0/24) to a single public address when exiting. With pf, that's one line:
pass in on int_if from 192.168.1.0/24 nat-to egressThis rule forwards all LAN traffic out through the egress interface, replacing the source address with the public address. nat-to replaces the old nat syntax in modern pf.
pfctl -sn
pfctl -sspfctl -sn shows the active NAT rules, pfctl -ss shows states including address translation. Both are essential for debugging.
rdr (redirect) forwards traffic from a public port to an internal server. This is how you publish a service without exposing the internal server directly:
pass in on egress proto tcp to port 8080 rdr-to 192.168.1.10 port 80
pass in on egress proto tcp to port 2222 rdr-to 192.168.1.10 port 22Requests to public port 8080 are forwarded to port 80 of server 192.168.1.10; public SSH on port 2222 is forwarded to internal SSH. For public HTTP, a common pattern is rdr-to 127.0.0.1 port 80 on the gateway itself:
pass in on egress proto tcp to port 443 rdr-to 127.0.0.1 port 443Warning
rdr only changes the destination — it doesn't bypass the firewall. Make sure there's a matching pass rule for that traffic, and for services forwarded to internal servers, watch for additional pass rules inside them. RDR and filtering work side by side, not as replacements for each other.
Tables store lists of addresses (IPs, networks) that can be changed without reloading the whole ruleset. Defining with persist keeps the table alive even when the ruleset is reloaded:
table <bruteforce> persist
table <badhosts> persist file "/etc/pf.badhosts"
block quick from <bruteforce>
block from <badhosts>The rule block quick from <bruteforce> immediately blocks everything on the list — without evaluating other rules. Table data can be added and removed at runtime:
pfctl -t badhosts -T add 203.0.113.5
pfctl -t badhosts -T show
pfctl -t badhosts -T delete 203.0.113.5Tables are the core mechanism for brute force mitigation — used in episode 14 alongside OpenSSH hardening.
When a ruleset grows large, a single file becomes hard to maintain. Anchors break the ruleset into independent parts:
anchor "ssh"
anchor "web"
load anchor "web" from "/etc/pf.web.conf"Anchors can be loaded from external files or even managed dynamically. A common anchor setup: one anchor for SSH rules (including rate-limiting), one for web, another for other services. Each part can be tested and reloaded without touching the others.
pfctl -a ssh -s rules
pfctl -a web -f /etc/pf.web.confpfctl -a targets a specific anchor. This is also the mechanism blacklistd and other mitigation tools use to add dynamic rules.
pf tracks state — every allowed connection is remembered so reply packets are automatically allowed without an explicit rule:
pass in on egress proto tcp to port 22 keep state
pass out on egress proto tcp to any keep statekeep state makes pf remember the connection; packets from the opposite direction that belong to the same connection are allowed without a new pass rule. This is why NAT works smoothly: state stores the address translation. Today keep state is the default, but writing it explicitly makes your intent clear.
Dynamic rules can also be combined with max to limit the number of states per address — the simplest form of anti-DoS:
pass in on egress proto tcp to port 22 max-src-conn 10, max-src-conn-rate 5/60That statement limits a maximum of 10 connections per source, with a maximum of 5 new connections per 60 seconds. Ideal for services prone to brute force.
Let's combine everything in one simple gateway:
int_if = "em1"
lan_net = "192.168.1.0/24"
set skip on lo
table <badhosts> persist file "/etc/pf.badhosts"
match in all scrub (no-df max-mss 1440)
block return log in all
pass in on egress proto tcp to port 22 max-src-conn 10, max-src-conn-rate 5/60
pass in on egress proto { tcp udp } to port 53
pass in on egress proto tcp to port 80
pass in on egress proto tcp to port 443
pass out on egress proto { tcp udp } to any
pass in on $int_if from $lan_net nat-to egress
pass in on egress proto tcp to port 8080 rdr-to 192.168.1.10 port 80
block from <badhosts>Remember: to forward between interfaces, IP forwarding must be active (episode 8). A firewall without forwarding is a static filter; with forwarding, it becomes a gateway.
Info
Always keep an administrative path. When a gateway handles forwarding and NAT, a small mistake can cut off your access to the system — and for a gateway, console or out-of-band access is highly recommended.
In episode 13 you leveled up pf: doing outbound NAT with nat-to, port forwarding with rdr-to, building living address lists with tables and persist, modularizing the ruleset with anchors, and relying on state tracking and limiting rules for dynamic defense.
Key takeaways:
nat-to egress; port forwarding uses rdr-to.persist) can be changed at runtime without a ruleset reload.keep state and connection limits make the firewall responsive to attacks.In the next episode, episode 14, we'll secure remote access: OpenSSH & hardening — configuring a modern sshd_config with ed25519 keys, disabling passwords and root login, using pf tables for brute force protection, and leveraging SSH tunneling.