Build a Safe PF Firewall Baseline on FreeBSD

John Burns

The old IP Filter article in this archive reflects an earlier FreeBSD firewall workflow. For a new FreeBSD host, start with PF and make the first policy deliberately small: retain console access, permit only the services the host provides, and validate the rules before enabling them at boot.

This is a documentation-verified baseline, not a copy-and-paste production policy. Interface names, management networks, service ports, IPv6 needs, and routing requirements vary by host. Apply it first from a console or an out-of-band management path so a typo does not strand an SSH-only system.

Decide the policy before writing rules

A useful initial policy answers four questions:

  1. Which interface receives untrusted traffic?
  2. Which administrative networks may reach SSH?
  3. Which public services must be reachable?
  4. Is this host a firewall/router, or only a protected endpoint?

Do not enable packet forwarding simply because PF is enabled. A normal server does not become a router. If the host really is a gateway, design NAT, forwarding, anti-spoofing, and internal-network rules as a separate reviewable change.

Enable PF without activating an unknown ruleset

FreeBSD documents PF in its firewall chapter. Enable it through /etc/rc.conf after a local rule-file test:

sysrc pf_enable="YES"

PF reads /etc/pf.conf by default. Make a backup before changing a host that already uses PF:

cp -p /etc/pf.conf /etc/pf.conf.pre-change

The following example assumes the host interface is <wan_if>, SSH administration is allowed only from <admin_net>, and HTTPS is the only public application service. Replace those placeholders before loading anything.

ext_if = "<wan_if>"
admin_net = "<admin_net>"

set block-policy drop
set skip on lo0
scrub in all fragment reassemble
block in all
pass out all keep state
pass in on $ext_if proto tcp from $admin_net to ($ext_if) port ssh keep state
pass in on $ext_if proto tcp to ($ext_if) port https keep state

set skip on lo0 leaves loopback traffic alone. scrub in all fragment reassemble asks PF to normalize fragmented input before rule evaluation. The default inbound block means each permitted service needs an explicit pass in rule. keep state allows response traffic for accepted connections without opening a broad return path.

If the host does not provide HTTPS, remove that line. If it needs DNS, mail, monitoring, or another service, add a narrowly scoped rule only after recording who needs access and why.

Validate before loading

Check syntax before changing the active ruleset:

pfctl -nf /etc/pf.conf

The -n option performs a syntax check without loading rules. If it reports an error, correct the file and rerun the check. Do not restart PF to discover a typo remotely.

When syntax is clean and a console is available, load the rules:

pfctl -f /etc/pf.conf
pfctl -sr

pfctl -sr displays the active filter rules. Confirm the displayed rules include the intended SSH management path before ending the console session.

Start the service after the initial ruleset is known-good:

service pf start

The pf_enable="YES" setting makes PF available during normal boot. Verify that persistence with a planned maintenance reboot or by checking the service state according to the host’s operating procedure.

Test the actual access paths

From an approved administrator address, open a second SSH connection before closing the first one. From an unapproved network, confirm that SSH is blocked. Test every intentionally exposed application service from the expected client network.

Use PF counters to see whether traffic reaches the rule you expected:

pfctl -vvs rules

If a connection fails, check the interface name, the address in admin_net, IPv4 versus IPv6 scope, DNS resolution, and any upstream firewall before weakening the policy. For an emergency rollback from the console, restore the backup, validate it, and reload it:

cp -p /etc/pf.conf.pre-change /etc/pf.conf
pfctl -nf /etc/pf.conf && pfctl -f /etc/pf.conf

Next steps

This baseline intentionally does not add NAT, rate limiting, tables, logging policy, or gateway behavior. Add those only when the host role requires them and test each addition independently. The current FreeBSD Handbook PF chapter should remain the authority for release-specific syntax and service management behavior.

Add visibility before complexity

A firewall that silently blocks traffic is difficult to operate. Before adding more rules, decide what evidence the host should retain when an expected connection fails. PF supports logging on selected rules, but logging every packet can create unnecessary volume. Begin with targeted logs around a service or source network being diagnosed, then inspect them using the host’s normal log collection path.

For repeated address sets, use a PF table rather than duplicating a long list inside several rules. Keep the table source under configuration management and document who may change it. Tables are useful for an approved administrative network list or a controlled block list; they are not a substitute for understanding why traffic is being permitted or denied.

IPv6 deserves its own review. A rule written only for IPv4 may leave IPv6 unintentionally open or unintentionally unusable, depending on the host configuration. Inventory addresses and listeners with sockstat, decide which protocols the host serves, and add explicit IPv6 treatment where it is required. Do not disable IPv6 merely to avoid designing the policy.

Change remote firewalls safely

For a remote system, prepare a rollback command before loading a new policy and use a short, supervised maintenance window. Keep the existing management connection open, establish a second connection from an approved address, then load and test the new rules. If the second connection cannot be established, restore the known-good ruleset from the first session or out-of-band console.

Avoid mixing a firewall rewrite with package upgrades, interface changes, or application deployment. A narrowly scoped change makes the cause of a failure visible and makes rollback faster. Record the successful ruleset, test results, and the change owner so a later operator can distinguish intentional policy from an abandoned experiment. Keep a dated copy of the known-good ruleset with the change record, not only on the host being protected.

Sources