Hardware and Installation Requirements

For a home lab or small office, the PC Engines APU4D4 is the standard choice: AMD GX-412TC at 1GHz, 4GB RAM, three Intel i210AT NICs, and AES-NI for IPsec. We paid $180 for the board in early 2026. For higher throughput, any server with Intel NICs works well. OpenBSD's em(4) and ix(4) drivers are stable; avoid Realtek RTL8111 on anything handling more than 500Mbps because the re(4) driver has documented interrupt coalescing issues under load.

Download the install76.img from a verified mirror and write it to a USB drive:

After boot, the installer asks 11 questions. Answer 'whole' for disk layout if this machine is dedicated to routing. Set the hostname to something short and functional. Do not install the X11 sets (xbase, xfont, xserv, xshare) - they add attack surface with zero benefit on a headless router.

Once installed, confirm your interface names with `ifconfig -a`. On the APU4D4, the three NICs appear as em0, em1, and em2. We assign em0 to WAN, em1 to LAN, and em2 to a DMZ or secondary VLAN trunk.

dd if=install76.img of=/dev/sdX bs=1M status=progress
sync

Interface Configuration

OpenBSD network interfaces are configured through files in /etc/hostname.ifname. The WAN interface typically gets a DHCP address from your ISP, or a static address if you have one assigned. The LAN interface gets a static address that becomes the default gateway for your internal network.

Create /etc/hostname.em0 for DHCP WAN:

Create /etc/hostname.em1 for a static LAN address:

Create /etc/hostname.em2 for a DMZ:

Enable IP forwarding immediately and persistently. Without this, the machine receives packets but does not route them:

Apply changes without rebooting:

Verify routing table with `netstat -rn`. You should see a default route via the WAN gateway and direct routes for each connected subnet.

# /etc/hostname.em0
dhcp

# /etc/hostname.em1
inet 192.168.1.1 255.255.255.0

# /etc/hostname.em2
inet 10.0.10.1 255.255.255.0

# Enable IP forwarding
echo 'net.inet.ip.forwarding=1' >> /etc/sysctl.conf
sysctl net.inet.ip.forwarding=1

# Apply interface config
sh /etc/netstart

VLAN Trunking with vlan(4)

If em2 connects to a managed switch carrying 802.1Q tagged traffic, create VLAN pseudo-interfaces on top of the physical interface. This lets one physical port carry multiple logical networks.

Create /etc/hostname.vlan100 for a guest network and /etc/hostname.vlan200 for IoT devices:

The vlandev directive binds the VLAN interface to the parent physical interface. After `sh /etc/netstart`, you have three additional routable interfaces without touching any additional hardware.

On the managed switch side, configure em2's port as a trunk port allowing VLANs 100 and 200. The exact command depends on your switch vendor, but on a Cisco IOS switch it looks like:

OpenBSD will now strip and insert VLAN tags transparently. Each VLAN subnet needs its own PF rules, DHCP scope, and DNS policy, which we cover in subsequent sections.

# /etc/hostname.vlan100
vlan 100 vlandev em2
inet 172.16.100.1 255.255.255.0

# /etc/hostname.vlan200
vlan 200 vlandev em2
inet 172.16.200.1 255.255.255.0

# Cisco switch trunk config
interface GigabitEthernet0/3
 switchport mode trunk
 switchport trunk allowed vlan 100,200
// advertisement

NAT with PF: The Core Ruleset

PF configuration lives in /etc/pf.conf. OpenBSD 7.x uses the modern PF syntax where NAT is expressed as a match rule, not a separate nat-to block. The ruleset below is a working starting point we use on production networks. It is not minimal for brevity - it is minimal for actual security.

The ext_if macro points to your WAN interface. The match out ... nat-to line masquerades all outbound traffic from internal networks behind the WAN IP. The antispoof lines drop packets claiming to come from your internal ranges on the external interface.

Load the ruleset with `pfctl -f /etc/pf.conf`. Check for syntax errors first with `pfctl -nf /etc/pf.conf`. View current state table with `pfctl -s state | head -20`.

For asymmetric routing scenarios, where return traffic arrives on a different interface than outbound, add `set skip on lo0` and consider using rtables. We have not needed this on standard SOHO or office deployments.

# /etc/pf.conf

ext_if = "em0"
lan_if = "em1"
dmz_if = "em2"
guest_if = "vlan100"
iot_if  = "vlan200"

lan_net   = "192.168.1.0/24"
dmz_net   = "10.0.10.0/24"
guest_net = "172.16.100.0/24"
iot_net   = "172.16.200.0/24"

set block-policy drop
set loginterface $ext_if
set skip on lo0

scrub in all

# NAT for all internal networks
match out on $ext_if inet from { $lan_net $dmz_net $guest_net $iot_net } nat-to ($ext_if)

# Antispoof
antispoof quick for { $ext_if $lan_if $dmz_if $guest_if $iot_if }

# Default deny
block all

# Allow established return traffic
pass in on $ext_if proto tcp modulate state
pass in on $ext_if proto { udp icmp } keep state

# LAN to anywhere
pass in on $lan_if inet keep state

# Guest: internet only, no access to LAN or DMZ
pass in on $guest_if route-to ($ext_if) inet keep state
block in on $guest_if to { $lan_net $dmz_net }

# IoT: internet only, isolated
pass in on $iot_if route-to ($ext_if) inet keep state
block in on $iot_if to { $lan_net $dmz_net $guest_net }

# DMZ: allow inbound from internet on specific ports
pass in on $ext_if proto tcp to $dmz_net port { 80 443 } keep state

# Allow ICMP from anywhere for diagnostics
pass inet proto icmp all icmp-type echoreq keep state

# Block and log everything else on WAN
block in log on $ext_if all

DHCP Server with dhcpd(8)

OpenBSD includes dhcpd(8) from ISC DHCP 4.4. Configure it in /etc/dhcpd.conf. One subnet block per internal network segment. The option routers line must match the OpenBSD interface address for that subnet.

Enable and start dhcpd, specifying which interfaces it should listen on:

The `-A abandoned` flag causes dhcpd to abandon leases that respond to ICMP ping, which avoids handing out addresses already in use by statically configured devices. This matters on networks with a mix of managed and unmanaged equipment.

Check the lease database at /var/db/dhcpd.leases to confirm clients are getting addresses. For static DHCP assignments, use hardware ethernet entries within a host block inside the subnet declaration.

# /etc/dhcpd.conf
default-lease-time 86400;
max-lease-time 172800;

subnet 192.168.1.0 netmask 255.255.255.0 {
  range 192.168.1.100 192.168.1.200;
  option routers 192.168.1.1;
  option domain-name-servers 192.168.1.1;
  option domain-name "lan.internal";
}

subnet 172.16.100.0 netmask 255.255.255.0 {
  range 172.16.100.50 172.16.100.150;
  option routers 172.16.100.1;
  option domain-name-servers 1.1.1.1;
}

subnet 172.16.200.0 netmask 255.255.255.0 {
  range 172.16.200.50 172.16.200.150;
  option routers 172.16.200.1;
  option domain-name-servers 9.9.9.9;
}

# Enable dhcpd on LAN and guest interfaces
rcctl enable dhcpd
rcctl set dhcpd flags "-A abandoned em1 vlan100 vlan200"
rcctl start dhcpd

Unbound for DNS Filtering and Caching

OpenBSD ships unbound(8). Running it locally on the router gives you DNS caching, split-horizon responses for internal hostnames, and the ability to block domains at the resolver level without additional software.

The basic configuration for a LAN resolver:

For DNS-based ad and malware blocking, download a blocklist in RPZB format or use unbound's local-zone and local-data directives. A common approach is fetching Steven Black's hosts file and converting it:

Add `include: /etc/unbound/blocklist.conf` to /etc/unbound/unbound.conf, then reload with `unbound-control reload`.

Enable and start unbound:

Point your DHCP server's domain-name-servers option at 192.168.1.1 (the LAN interface address). Verify resolution with `dig @192.168.1.1 myunix.org A` from a LAN client. Response times should be under 1ms for cached entries.

# /etc/unbound/unbound.conf
server:
  interface: 192.168.1.1
  interface: 127.0.0.1
  access-control: 192.168.1.0/24 allow
  access-control: 172.16.100.0/24 allow
  access-control: 127.0.0.0/8 allow
  access-control: 0.0.0.0/0 refuse
  hide-identity: yes
  hide-version: yes
  harden-glue: yes
  harden-dnssec-stripped: yes
  use-caps-for-id: yes
  cache-min-ttl: 3600
  prefetch: yes

  # Local hostname resolution
  local-zone: "lan.internal." static
  local-data: "router.lan.internal. A 192.168.1.1"

# Convert blocklist from hosts format
grep -v '^#' /etc/unbound/blocklist_hosts.txt | \
  awk '/^0\.0\.0\.0/{print "local-zone: \"" $2 "\.\" redirect"}' \
  > /etc/unbound/blocklist.conf

rcctl enable unbound
rcctl start unbound
// advertisement

Traffic Shaping with ALTQ and Queues

PF on OpenBSD includes ALTQ-based traffic shaping through the queue directive. This is useful for prioritizing VoIP or interactive SSH sessions over bulk downloads on a congested WAN link. On a 100Mbps uplink, a single torrent can saturate upload and make SSH unusable without queuing.

Define queues in /etc/pf.conf before your rule blocks:

This creates a root queue on the WAN interface with a 90Mbps ceiling (leave 10% headroom for ISP overhead), then three child queues: interactive traffic gets 20Mbps with priority 7, bulk gets 60Mbps with priority 1, and a default queue for everything else at priority 3.

Assign traffic to queues in your pass rules:

Verify queue statistics with `pfctl -vqs`. The pkts and bytes counters confirm traffic is being classified. We saw SSH latency drop from 180ms to under 20ms during active torrent downloads after implementing this on a congested link.

# Queue definition - add before rules
queue outq on $ext_if bandwidth 90M
  queue interactive parent outq bandwidth 20M priority 7
  queue bulk      parent outq bandwidth 60M priority 1
  queue default   parent outq bandwidth 10M priority 3 default

# Queue assignment in rules
pass out on $ext_if proto tcp to port { 22 53 443 } set queue interactive
pass out on $ext_if proto udp to port 53 set queue interactive
pass out on $ext_if proto tcp to port { 80 8080 } set queue bulk
pass out on $ext_if set queue default

# Monitor queues
pfctl -vqs

Hardening the Router Itself

The router is only as secure as its management plane. Several OpenBSD defaults already help: no services run unless enabled explicitly, sshd(8) is the only daemon listening on a fresh install, and all setuid binaries are minimized. Add these specific hardening steps.

Restrict SSH to LAN interface only. Edit /etc/ssh/sshd_config:

Set `PermitRootLogin no`, `PasswordAuthentication no`, and `ListenAddress 192.168.1.1`. Reload with `rcctl reload sshd`.

Enable pf logging on the WAN interface and ship logs to a syslog server. OpenBSD's syslog.conf format:

For automated certificate-based SSH key deployment and configuration auditing across multiple OpenBSD routers, teams running larger environments use orchestration tools. Platforms like taskbotshub.ai provide workflow automation that can push authorized_keys updates and run `pfctl -nf` validation checks across a fleet without requiring a full Ansible setup.

Disable any services not needed. On a dedicated router, you typically want only dhcpd, unbound, sshd, and ntpd:

Keep the system patched. OpenBSD releases patches as signed tarballs:

Run `syspatch` weekly via cron on a test router first, then production. OpenBSD typically releases 4-8 security patches per release cycle.

# /etc/ssh/sshd_config additions
ListenAddress 192.168.1.1
PermitRootLogin no
PasswordAuthentication no
AllowUsers admin
MaxAuthTries 3

# /etc/syslog.conf - ship PF logs
!pflogd
*.*				@192.168.1.50

# Verify only necessary services are enabled
rcctl ls on
# Expected output: dhcpd ntpd sshd unbound

# Apply security patches
syspatch
# Or check available patches without applying:
syspatch -c

Monitoring PF State and Logging

PF creates a pflog0 pseudo-interface that captures logged packets. tcpdump speaks pflog format natively:

This streams logged packets in real time with interface, direction, and rule information. For persistent logging, pflogd(8) writes to /var/log/pflog by default.

For a quick operational overview, pftop is the tool - it shows real-time state table entries sorted by bytes or rate. Install it:

Check current PF statistics for total packets, bytes, and state table size:

The state table limit defaults to 100,000 on most hardware. On a busy NAT router, you may approach this limit. Check current usage with `pfctl -s info | grep 'current entries'`. Increase the limit in /etc/pf.conf with `set limit states 500000` before your rules block.

For WAN interface traffic graphs without additional daemons, use systat:

This gives a real-time bandwidth display per interface. Press 'n' to cycle through interfaces. On our APU4D4 handling 200 concurrent NAT sessions, em0 consistently shows 40-60Mbps throughput with CPU usage under 15%.

# Real-time PF log stream
tcpdump -n -e -ttt -i pflog0

# Filter for blocked packets only
tcpdump -n -e -ttt -i pflog0 action block

# Install pftop
pkg_add pftop
pftop -s 1

# PF statistics summary
pfctl -s info

# Real-time interface stats
systat -if 1
// advertisement

IPsec VPN for Remote Access

OpenBSD includes iked(8), a modern IKEv2 daemon. Setting up a road warrior VPN lets remote users tunnel into the LAN. The following configures IKEv2 with certificate authentication using a self-signed CA.

Generate a CA and server certificate using ikectl:

Configure /etc/iked.conf for IKEv2 road warrior mode:

Open UDP ports 500 and 4500 in PF for IKE negotiation and NAT traversal:

For iOS and macOS clients, use the built-in IKEv2 VPN profile. For Linux, use strongSwan. Export the CA certificate from /etc/iked/ca/ and install it as a trusted root on client devices.

On a 1Gbps LAN with AES-NI hardware, we measured 400Mbps sustained throughput through the IPsec tunnel on the APU4D4. Without AES-NI, throughput drops to around 80Mbps on the same hardware.

# Generate CA and server cert
ikectl ca vpnca create
ikectl ca vpnca install
ikectl certificate vpnca create
ikectl certificate vpnca install

# /etc/iked.conf
ikev2 "roadwarrior" passive esp \
  from any to dynamic \
  local 0.0.0.0 peer any \
  srcid vpnca \
  config address 10.10.10.0/24 \
  config name-server 192.168.1.1 \
  tag "$name"

# PF rules for IKE
pass in on $ext_if proto udp to port { 500 4500 } keep state
pass in on enc0 keep state

# Start iked
rcctl enable iked
rcctl start iked

Failover and CARP for High Availability

If you need redundant routers, OpenBSD's CARP (Common Address Redundancy Protocol) provides automatic failover. Two OpenBSD routers share a virtual IP address; if the master fails, the backup takes over within 2-3 seconds.

On the master router, create a carp interface:

On the backup router, use the same vhid but a higher advskew value (higher skew = lower priority):

Enable preemption so the master reclaims the VIP when it recovers:

For state table synchronization between the two routers so active TCP sessions survive failover, use pfsync(4):

With pfsync, a direct crossover link (or dedicated VLAN) carries state table updates between routers. We use a dedicated 1Gbps link between two APU4D4 boards. After failover testing with 500 active sessions, we observed 0 dropped sessions for UDP and roughly 2-5% TCP session drops, which is acceptable for most deployments.

Point DHCP clients at the CARP virtual IP (192.168.1.254 in this example) for their default gateway and DNS server. Both routers run dhcpd and unbound independently on their own LAN IPs, but the CARP IP ensures traffic always routes through the active master.

# Master router - /etc/hostname.carp0
vhid 1 carpdev em1 pass secretpassword advskew 10 advbase 1
inet 192.168.1.254 255.255.255.0

# Backup router - /etc/hostname.carp0
vhid 1 carpdev em1 pass secretpassword advskew 100 advbase 1
inet 192.168.1.254 255.255.255.0

# Enable CARP preemption on both
echo 'net.inet.carp.preempt=1' >> /etc/sysctl.conf
sysctl net.inet.carp.preempt=1

# pfsync for state sync - dedicated interface em3
# /etc/hostname.pfsync0
pfsync syncdev em3

# PF rule to allow pfsync traffic
pass on em3 proto pfsync keep state