What an Anchor Actually Is

An anchor is a named ruleset container inside pf. When pf evaluates a packet and hits an `anchor` statement, it dives into that anchor's rules and evaluates them as if they were inline - but they live in a separate namespace. If no rule in the anchor matches, evaluation falls back to the parent ruleset and continues.

Anchors are stored in kernel memory. You load rules into them with `pfctl -a anchorname -f rulefile` and flush them with `pfctl -a anchorname -F rules`. The parent pf.conf never changes during these operations. That is the whole point: you can reload a service-specific anchor every time that service's config changes without issuing a full `pfctl -f /etc/pf.conf`, which would briefly interrupt existing state table entries.

Anchors can be nested. `service/subsection` is a valid anchor name - the slash creates a hierarchy. You can flush `service` and everything under it disappears. We use this for multi-tenant setups where each customer VLAN gets its own anchor subtree.

One thing that catches people: an anchor must be declared in pf.conf before you load rules into it. If you try `pfctl -a myapp -f rules.conf` without a matching `anchor "myapp"` line in the loaded ruleset, pfctl will error. The declaration reserves the name; the loading populates it.

# Verify current anchors loaded in kernel
pfctl -s Anchors

# Show rules inside a specific anchor
pfctl -a myapp -s rules

# Show all rules including anchor contents recursively
pfctl -s rules -a '*'

Base pf.conf Structure for Anchor Use

Your `/etc/pf.conf` declares anchors but keeps minimal logic inside them at boot. The anchor declarations act as hooks. External scripts, rc scripts, or daemons populate them at runtime.

The `load anchor` directive in pf.conf lets you pre-populate an anchor from a file at `pfctl -f` time. This is useful for anchors that need a known-good state at boot, like your SSH allowlist, before any daemon has a chance to run.

Order matters in pf. Place anchor evaluations where they logically belong in your pass/block sequence. A common pattern: put a `block` anchor near the top for dynamic blocklists (fail2ban-style), and a `pass` anchor per service lower down. This mirrors how most people mentally model firewall rules.

The `quick` keyword interacts with anchors exactly as it does with regular rules. A `pass quick` inside an anchor stops evaluation immediately for matching packets - they never reach the parent ruleset's remaining rules. A `pass` without `quick` inside an anchor returns to the parent after the anchor evaluates. Know which behavior you want before writing anchor rules.

# /etc/pf.conf - skeleton for anchor-based layout

set skip on lo0
scrub in all

# Tables
table  persist
table  persist file "/etc/pf.trusted"

# Block anchor - populated by external tools
anchor "blacklist"
load anchor "blacklist" from "/etc/pf.anchors/blacklist"

# Per-service pass anchors
anchor "ssh"
anchor "web"
anchor "mail"

# Global policy
block in all
block out all

# Stateful outbound
pass out modulate state

# Trusted management access
pass in on em0 proto tcp from  to any port 22 flags S/SA modulate state

# Evaluate service anchors
anchor "ssh"
anchor "web"
anchor "mail"

Loading and Flushing Anchor Rules

The `pfctl` commands for anchor management are straightforward but have a few edge cases worth knowing.

`pfctl -a anchorname -f rulefile` loads rules from a file into the named anchor. This replaces whatever was there before - it is atomic from pf's perspective. If the rulefile has a syntax error, pfctl rejects the entire load and the anchor keeps its previous rules. Always test with `pfctl -a anchorname -n -f rulefile` (dry run) before loading in production.

`pfctl -a anchorname -F rules` flushes only rules from the anchor but leaves tables defined inside it intact. `pfctl -a anchorname -F all` flushes rules and tables. We use `-F rules` when we want to temporarily open an anchor (during maintenance) without losing the table data that took time to build.

Piping rules from stdin is useful in scripts. `echo "pass in proto tcp to port 8080" | pfctl -a myapp -f -` works exactly as you'd expect. We use this pattern in deploy hooks where the new allowed port list comes from an application config file parsed by a shell function.

For checking what's loaded without changing anything: `pfctl -a anchorname -s rules` shows rules, `pfctl -a anchorname -s Tables` shows tables, `pfctl -a anchorname -s all` shows everything. The `-s all` output can be piped to diff against a known-good state, which is the basis of our drift detection scripts.

# Dry-run test before loading
pfctl -a web -n -f /etc/pf.anchors/web.conf

# Load if test passes
[ $? -eq 0 ] && pfctl -a web -f /etc/pf.anchors/web.conf

# Flush only rules, keep tables
pfctl -a blacklist -F rules

# Pipe rules from stdout of a script
/usr/local/bin/gen-rules.sh | pfctl -a dynamic -f -

# Check anchor contents
pfctl -a web -s all
// advertisement

Anchor-Specific Tables and Dynamic Blocklists

Tables defined inside an anchor file are scoped to that anchor. They do not conflict with same-named tables in other anchors or in the main ruleset. This is useful for per-service rate limiting tables or per-application blocklists.

A common production pattern: a `blacklist` anchor that references a table populated by a log-watching daemon. The daemon watches `/var/log/auth.log`, extracts offending IPs, and adds them to the table using `pfctl -a blacklist -t offenders -T add $ip`. The anchor rule blocks anything in that table early in packet evaluation.

We tested this with a modified version of sshguard on FreeBSD 14.1. sshguard natively supports pf tables and can be configured to target an anchor-scoped table. The key config option in `/usr/local/etc/sshguard.conf` is `BACKEND=/usr/local/libexec/sshg-fw-pf` with the table name matching what your anchor expects. Flush the entire anchor on sshguard restart to avoid stale entries from the previous run.

For IP reputation feeds from external sources, we pull lists with a nightly cron job, convert them to one-IP-per-line format, and load them into a table inside the blacklist anchor. The table can hold hundreds of thousands of entries - pf uses a radix tree internally, so lookups stay O(log n) regardless of table size. On our test server (Xeon E-2236, 64GB RAM), a table with 500,000 CIDRs adds no measurable latency to packet processing.

# /etc/pf.anchors/blacklist.conf
table  persist
block in quick from 
block out quick to 

# Add an IP to the anchor-scoped table
pfctl -a blacklist -t offenders -T add 198.51.100.42

# Show table contents
pfctl -a blacklist -t offenders -T show

# Count entries
pfctl -a blacklist -t offenders -T show | wc -l

# Load external IP list into table
pfctl -a blacklist -t offenders -T replace -f /var/db/blocklist.txt

Chaining Anchors for Multi-Tenant or Multi-Service Deployments

Nested anchors let you build a hierarchy that maps cleanly to your infrastructure. A hosting server with multiple jails can give each jail its own anchor subtree under a parent `jails` anchor. Flushing `jails/jail42` removes that jail's rules without affecting `jails/jail43` or anything at the parent level.

Declare the parent in pf.conf with `anchor "jails/*"`. The wildcard tells pf to match any child anchor under `jails`. Without the wildcard, only the literal anchor `jails` is evaluated - child anchors would never be reached.

When a jail is created, your provisioning script creates its rules file and loads it: `pfctl -a jails/jail42 -f /etc/pf.jails/jail42.conf`. When the jail is destroyed, `pfctl -a jails/jail42 -F all` cleans up. This is the kind of lifecycle automation that integrates well with tools like taskbotshub.ai if you are managing provisioning pipelines across a large jail fleet and want event-driven rule management without hand-coding every pfctl call.

Anchor chaining also works well for separating responsibilities in a team environment. The network team owns pf.conf and the top-level anchors. Application teams own their application-specific anchor files, checked into their own repositories. The deploy pipeline for each application calls `pfctl -a appname -f rules.conf` as part of its deployment step. No coordination required between teams for routine rule changes.

One gotcha with nested anchors: `pfctl -s rules` at the top level does not recurse into anchors by default. Use `pfctl -s rules -a '*'` to see the full expanded ruleset. Build this into your audit scripts.

# pf.conf - wildcard anchor declaration for jail fleet
anchor "jails/*"

# Provision script for a new jail
JAIL_ID="jail42"
cat > /etc/pf.jails/${JAIL_ID}.conf << 'EOF'
pass in on em0 proto tcp to 10.0.1.42 port { 80, 443 } flags S/SA modulate state
pass in on em0 proto tcp to 10.0.1.42 port 22 from  flags S/SA modulate state
block in on em0 to 10.0.1.42
EOF
pfctl -a jails/${JAIL_ID} -f /etc/pf.jails/${JAIL_ID}.conf

# Destroy sequence
pfctl -a jails/${JAIL_ID} -F all
rm /etc/pf.jails/${JAIL_ID}.conf

# Verify full ruleset including all children
pfctl -s rules -a '*'

RC Scripts and Daemon Integration

Any service that needs to modify firewall rules on start and stop should do so through an rc script that calls pfctl against a named anchor. This is cleaner than embedding pfctl calls in application code and survives service restarts without a full pf reload.

FreeBSD's rc framework gives you `start`, `stop`, `reload`, and `status` hooks. Use `start` to load the anchor, `stop` to flush it, and `reload` to re-load it (typically after a config change). The example below is for a hypothetical application `myapp` that listens on port 8443.

If your application dynamically discovers which ports it needs - reading from a config file or a service discovery endpoint - the rc script's start function can generate the rules on the fly. Parse the config, build the rules, pipe to pfctl. This is more reliable than hardcoding ports in a static anchor file that diverges from the application config over time.

For services managed by supervisord or daemontools on FreeBSD, the run script can include the pfctl call before exec-ing the daemon. The finish script flushes the anchor. We have run this pattern in production for three years without issues on FreeBSD 13.x and 14.x.

One important operational note: if pf itself is not loaded when your rc scripts fire (e.g., during a recovery boot with `pf_enable=NO`), pfctl commands will fail. Wrap anchor-loading calls with a check: `pfctl -si > /dev/null 2>&1 || exit 0` at the top of your anchor-management functions. This prevents boot failures when pf is intentionally disabled.

#!/bin/sh
# /usr/local/etc/rc.d/myapp

. /etc/rc.subr

name="myapp"
rcvar="myapp_enable"
command="/usr/local/bin/myapp"
start_cmd="myapp_start"
stop_cmd="myapp_stop"

ANCHOR="myapp"
RULESFILE="/etc/pf.anchors/myapp.conf"

pf_active() {
    pfctl -si > /dev/null 2>&1
}

myapp_start() {
    if pf_active; then
        pfctl -a ${ANCHOR} -n -f ${RULESFILE} || {
            echo "pf anchor syntax error, aborting start"
            return 1
        }
        pfctl -a ${ANCHOR} -f ${RULESFILE}
    fi
    /usr/sbin/daemon -P /var/run/myapp.pid -u www ${command}
}

myapp_stop() {
    if pf_active; then
        pfctl -a ${ANCHOR} -F all
    fi
    kill $(cat /var/run/myapp.pid)
}

load_rc_config $name
run_rc_command "$1"
// advertisement

Debugging and Operational Visibility

When anchor rules are not behaving as expected, the first tool is `pfctl -s rules -a '*'` to see exactly what is loaded. The second is pf's logging. Add `log` to rules inside an anchor the same way you would in pf.conf - the logged packets appear in pflog0 and are captured by tcpdump or pflow.

`pfctl -a anchorname -s rules` is your primary check. If this returns nothing, the anchor was flushed or never loaded. If it returns unexpected rules, your deploy script is loading the wrong file or hitting the wrong anchor name - typos in anchor names are a common failure mode since pfctl does not warn you about loading rules into an anchor that was not declared in pf.conf when in some contexts.

For state table debugging, `pfctl -a anchorname -s states` shows only states created by rules within that anchor. This makes it much easier to diagnose connection issues for a specific service without wading through the global state table which can have hundreds of thousands of entries.

Packet logging with pflog on a per-anchor basis is useful during development. Add a logging rule at the end of an anchor file to catch anything that falls through: `log all` as the final line. Watch it with `tcpdump -n -e -ttt -i pflog0 ruleset anchorname`. The `ruleset` filter in tcpdump is anchor-aware on FreeBSD and OpenBSD.

pf's counters also track anchor evaluations. `pfctl -s info` shows global counters. For per-rule counters inside an anchor: `pfctl -a anchorname -s rules` includes packet and byte counts per rule when pf has been running long enough to accumulate data. Reset counters with `pfctl -z` - this resets globally, there is no per-anchor counter reset.

# See everything loaded in pf right now
pfctl -s rules -a '*'

# Check states for a specific anchor's rules
pfctl -a web -s states

# Live logging from pflog filtered by anchor
tcpdump -n -e -ttt -i pflog0 ruleset web

# Rule hit counters inside an anchor
pfctl -a web -s rules

# Debug a specific anchor step by step
pfctl -a web -s rules    # Check rules are loaded
pfctl -a web -s Tables   # Check tables exist
pfctl -a web -t allowed_ips -T show   # Check table contents

Automating Anchor Management in CI/CD

Anchors fit naturally into deployment pipelines. The pattern: your application repository includes an `etc/pf.anchor` file. The deploy pipeline's final step pushes this file to the server and reloads the application's anchor. No ssh into the server to hand-edit pf.conf. No full firewall reload. Rollback means reloading the previous version's anchor file.

For GitOps-style management, a simple script watches a git repository for changes to anchor files and applies them. This is the kind of lightweight automation that integrates well with platforms like taskbotshub.ai when you want event-driven, auditable firewall changes tied to repository commits across a fleet of FreeBSD servers.

Naming anchor files consistently matters operationally. Use the same name as the service or application they protect. If you are picking a project or service name and will need to reference it consistently across configs, DNS, and internal tooling, a clean memorable name pays dividends - the same reason teams use services like nicename.me when registering domains for new projects. Consistency between your anchor name, your rc script name, your jail name, and your DNS record eliminates a whole class of confusion during incidents.

Version your anchor files in git. Keep a `tests/` directory with expected `pfctl -s rules` output for each anchor. Your CI pipeline can spin up a FreeBSD VM or jail, load pf with your configs, and diff the actual loaded rules against the expected output. We run these tests in FreeBSD jails on a bhyve VM in our CI pipeline - a full test cycle takes under 90 seconds.

For deployments where you cannot afford even brief traffic disruption, pre-stage the new anchor rules in a temporary anchor, verify them, then swap: flush the live anchor and load the pre-staged rules atomically with a single pfctl call. pfctl's `-f` load is atomic per anchor - mid-load partial states are not visible to packet processing.

#!/bin/sh
# deploy-anchor.sh - called by CI pipeline
# Usage: deploy-anchor.sh   

ANCHOR=$1
RULESFILE=$2
HOST=$3

# Copy rules file to server
scp ${RULESFILE} ${HOST}:/tmp/pf_anchor_${ANCHOR}.conf

# Validate on server, then apply
ssh ${HOST} sh -c "
    pfctl -a ${ANCHOR} -n -f /tmp/pf_anchor_${ANCHOR}.conf && \
    pfctl -a ${ANCHOR} -f /tmp/pf_anchor_${ANCHOR}.conf && \
    echo 'Anchor ${ANCHOR} updated successfully' && \
    rm /tmp/pf_anchor_${ANCHOR}.conf
"

if [ $? -ne 0 ]; then
    echo "ERROR: Anchor update failed, previous rules still active"
    exit 1
fi