Building an OpenBSD Home Router, Part 1: The Hardware

Last month I was reading through the privacy policy of my ISP here in Cyprus. Not for fun, obviously. Nobody reads those for fun. I was looking for something specific about data retention, and halfway down page eleven I found a sentence that made me put my coffee down.

It said, more or less: we may use DNS query data to improve our services and share aggregated data with third parties.

Let me translate that from corporate to English: every single domain you look up, every website you visit, every API endpoint your code calls, every IoT device phoning home at 3am, all of it flows through their DNS resolver, gets logged, and gets monetised. Your DNS is their bread and butter. They don’t need to do deep packet inspection. They don’t need to break TLS. They just need to watch where you’re pointing, and that’s enough to build a remarkably detailed picture of your life.

The ISP-provided router is optimised for exactly one thing, and it’s not your privacy. It’s their data collection. The DNS settings are locked down. The firmware is a black box. The admin interface gives you just enough control to feel like you’re in charge while ensuring you absolutely are not.

I’d been running my own firewall for years already, on a tiny PC Engines ALIX board [1] that had served me brilliantly. But the old hardware was showing its age, and I wanted to do it properly this time. Faster NICs. A 64-bit OS. Better crypto in hardware. So I did what any reasonable person would do: I bought another small fanless board and committed to a week of serial console configuration.

This is Part 1 of a six-part series about building a home firewall and router from scratch on OpenBSD. This first post is about the hardware and network design. The decisions you make here constrain everything that follows, so it’s worth getting them right.

tl;dr, I replaced my ISP’s router with a PC Engines APU3D2 running OpenBSD. Three Gigabit NICs, fanless cooling, serial console only. The network splits into WAN, wired LAN, and WiFi LAN on separate subnets. This post covers why, and what the hardware looks like.

Why Build Your Own Router

I can already hear someone saying “just install pfSense on an old laptop” or “get a Ubiquiti EdgeRouter, they’re fine.” And yes, they’re fine. For most people. But “fine for most people” has never been a phrase that stops me from spending a weekend on something.

Here’s what I actually wanted:

Full control over DNS. I run Unbound as a local recursive resolver. No forwarding to Cloudflare, no forwarding to Google, no forwarding to my ISP. My DNS queries go directly to the authoritative nameservers. Nobody in the middle gets to log them, aggregate them, or sell them. This alone is worth the effort.

Real firewall rules. Not the “allow/deny by application” theatre that consumer routers offer. I mean pf [2]. Stateful packet filtering with sane syntax, proper NAT, traffic shaping, and the ability to write rules that actually express what I want the network to do.

Network segmentation. I want my wired LAN and my WiFi network on different subnets with explicit rules about what can cross between them. Not because I’m paranoid (well, a bit), but because it’s good practice and it makes debugging so much easier when you can see exactly which segment a problem lives on.

A system I understand completely. No proprietary firmware. No cloud management portal. No “phone home to check your license” nonsense. If something breaks at 2am, I want to be able to SSH into the box, read the configs, and fix it. I don’t want to file a support ticket with a company that might not exist next year.

You can’t do any of this properly with consumer gear. You can approximate some of it. But the gap between “approximate” and “actual” is where all the interesting problems live.

The Old Guard: ALIX 2D3

Before I talk about the new hardware, a brief eulogy for the board that got me here.

The PC Engines ALIX 2D3 [1] was a gorgeous little thing. AMD Geode LX800, 500 MHz, 256 MB RAM, three 10/100 VIA Ethernet ports. It ran i386 OpenBSD 7.6, and it ran it well. The Geode was a 32-bit processor with no crypto acceleration, so anything involving TLS was, shall we say, leisurely. But for basic NAT and packet filtering? Rock solid. I ran that board for years and it never once crashed, locked up, or needed a reboot that wasn’t a planned upgrade.

The problem was threefold. First, the NICs were 10/100. When your ISP starts offering 100 Mbps fibre and your firewall is the bottleneck, that’s embarrassing. Second, i386 OpenBSD was approaching end of life, the community was clearly moving to amd64, and I didn’t fancy being the last person on the platform when security patches stopped. Third, AES-NI. The Geode didn’t have it. The new board does. For VPN throughput, that’s not a nice-to-have, it’s the difference between usable and unusable.

So the ALIX got retired to the shelf of honour (next to the Raspberry Pi that I keep meaning to do something with), and I ordered its replacement.

The New Hardware: PC Engines APU3D2

The APU3D2 [3] is, to my eyes, what a router should look like. It’s a 6x6 inch board in an aluminium case with no fans, no moving parts, and nothing that can go wrong mechanically. It sits on a shelf, draws somewhere between 6 and 12 watts, and just… works.

Here’s what’s on it:

CPU: AMD Embedded G series GX-412TC. Quad-core Jaguar at 1 GHz, 64-bit, with AES-NI. The Jaguar microarchitecture isn’t going to win any benchmarks against a modern desktop chip, but that’s not the point. For routing, NAT, and packet filtering, it’s wildly overpowered. The AES-NI support is the real prize here. Hardware-accelerated AES means VPN tunnels (WireGuard, IPsec, OpenVPN) run at wire speed instead of hammering the CPU.

RAM: 2 GB DDR3-1333. More than enough. OpenBSD’s kernel is lean. pf’s state tables are efficient. Unless you’re doing something genuinely unusual, 2 GB is generous for a firewall.

NICs: 3x Intel i211AT Gigabit Ethernet. This is where the ALIX-to-APU upgrade really shines. Three Intel Gigabit ports, each on its own PCI Express lane, each driven by OpenBSD’s em(4) driver [5]. Intel NICs have the best driver support in the BSD world, and the i211AT [6] is no exception. They just work. No firmware blobs, no binary drivers, no surprises. The jump from VIA 10/100 to Intel Gigabit is, frankly, absurd. It’s like going from a bicycle to a motorcycle.

Storage: Kingston mSATA SSD. The APU3D2 boots from mSATA. I put a small Kingston SSD in there. For a firewall that mostly runs from RAM, even a cheap mSATA is fine. I’m not doing heavy writes. The filesystem is mostly read-only in normal operation.

Firmware: coreboot. Not tinyBIOS like the ALIX. The APU3D2 runs coreboot [4], the open-source firmware project. This matters because coreboot is auditable, updateable, and doesn’t contain whatever mystery code your BIOS vendor decided to shove in there. It also means the serial console runs at 115200 baud, which is a step up from the ALIX’s 38400.

Cooling: Passive. The CPU is thermally bonded to the aluminium enclosure via a heat spreader. No fans. No dust filters. No bearings to fail. I’ve had this board running in a Larnaca summer, ambient temperature north of 35 degrees, and it’s been perfectly happy. Fanless is not optional for something that needs to run 24/7 for years.

The Upgrade at a Glance

For the hardware nerds, here’s the before and after:

ALIX 2D3APU3D2
CPUAMD Geode LX800, 500 MHzAMD GX-412TC, 1 GHz quad-core
Architecturei386 (32-bit)amd64 (64-bit)
RAM256 MB DDR2 GB DDR3-1333
NICs3x VIA VT6105M 10/1003x Intel i211AT Gigabit
Crypto accel.NoneAES-NI
FirmwaretinyBIOScoreboot
Serial baud38400115200
Power draw~5W6-12W

Every column is an upgrade. The power draw went up slightly, but for a device that’s on 24/7, we’re talking about the difference between 44 kWh/year and 105 kWh/year at peak. In Cyprus electricity prices, that’s maybe an extra two euros a month. I’ll survive.

Network Topology: Three Segments

The APU3D2 has three Gigabit NICs, and I’m using all three. The topology is deliberately simple because simple topologies are debuggable topologies.

                    ┌──────────────┐
    ISP Fibre ────► │ Cable Modem  │ (DHCP)
                    │              │
                    └──────┬───────┘
                           │
                           │ em0 (WAN)
                    ┌──────┴───────┐
                    │              │
                    │   APU3D2     │
                    │   OpenBSD    │
                    │              │
                    ├──────┬───────┤
                    │ em1  │ em2   │
                    └──┬───┘───┬───┘
                       │       │
              ┌────────┴──┐ ┌──┴─────────┐
              │  Gigabit  │ │  TP-Link   │
              │  Switch   │ │ Archer     │
              │           │ │ C2300      │
              └─────┬─────┘ │ (AP mode)  │
                    │       └──┬─────────┘
              Wired LAN        │
              Desktops       WiFi LAN
              10.20.10.0/24  Laptops, phones
                             10.20.20.0/24

em0: WAN

The WAN interface is set to request DHCP from the ISP’s cable modem. em0 is configured as a DHCP client, we just accept whatever IP the cable modem gives us and clean everything up internally. The modem is not trusted to handle the firewall or DNS. All the intelligence lives on my hardware, running my software, under my control. pf handles the NAT with (egress:0), which dynamically tracks whatever IP lands on em0, so we don’t need to reload firewall rules when the ISP changes our address.

em1: Wired LAN (10.20.10.0/24)

em1 connects to a Gigabit switch, which feeds the wired desktops. Linux workstations, a NAS, anything that benefits from Gigabit throughput and doesn’t need to move around. The APU3D2 runs dhcpd on this interface, handing out addresses in the 10.20.10.0/24 range. The router itself sits at 10.20.10.1.

em2: WiFi LAN (10.20.20.0/24)

em2 goes to a TP-Link Archer C2300, which is configured as a wireless access point in bridge mode. The Archer does NO routing, no NAT, no DHCP. It’s purely an access point. All the routing and DHCP for the WiFi segment comes from the APU3D2 on em2. WiFi clients get addresses in the 10.20.20.0/24 range. The router is at 10.20.20.1 on this interface.

This is a conscious design choice. I could have run the WiFi off the wired switch and put everything on one subnet. But separate subnets let me write different pf rules for wired and wireless clients, and that matters. I trust the wired desktops more than I trust whatever random IoT device someone’s phone decides to put on the WiFi.

Why 10.20.x.x and Not 172.x.x.x

RFC 1918 [7] gives us three private ranges to play with: 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. My old network used 172.20.x.x. It seemed like a perfectly reasonable choice at the time. Then Docker happened.

Docker’s default bridge network pool draws from the 172.17.0.0/16 range [8], and its user-defined networks will happily allocate subnets from the rest of 172.16.0.0/12. If you’re a developer running Docker on a machine that’s also on a 172.20.x.x LAN, you WILL get routing collisions. Your containers will try to reach hosts on your LAN and end up talking to the Docker bridge instead. Or worse, it’ll work intermittently, which is the most maddening kind of failure.

I know this because it happened. One of the developers on our team was running Docker Compose for a local dev environment, and suddenly couldn’t SSH to the build server. Took us an embarrassingly long time to figure out that Docker had allocated a bridge network that overlapped with our LAN subnet. The fix was simple once we understood it, but the debugging was… not fun.

So the new network uses 10.20.x.x. The 10.0.0.0/8 range is large enough that you can carve out subnets that don’t collide with Docker’s defaults, AWS VPC defaults, or any other “helpful” software that allocates private addresses without asking. 10.20.10.0/24 for wired, 10.20.20.0/24 for WiFi. Clean, memorable, collision-free.

Inter-LAN Routing

The wired and WiFi subnets can talk to each other. I know, I know. Purists would say “the whole point of segmentation is isolation.” And they’re right in a corporate context. But this is a home network with a specific workflow: the MacBooks live on WiFi, the Linux desktops live on the wired switch, and I SSH from the MacBook to the Linux box approximately six hundred times a day.

Blocking inter-LAN traffic would mean I’d need to VPN into my own house to reach my own desktop. That’s a level of paranoia I’m not willing to inflict on myself. So the pf rules permit traffic between 10.20.10.0/24 and 10.20.20.0/24. It’s a conscious trade-off. I know what I’m giving up (WiFi-to-wired isolation), and I know what I’m getting (a workflow that doesn’t make me want to throw things).

IPv6: Not Today

IPv6 is explicitly disabled on all interfaces. I realise this is slightly heretical in 2025. I’ve got nothing against IPv6 in principle. But dual-stack adds complexity to the firewall rules, to the DNS configuration, and to the debugging process. My ISP doesn’t provide native IPv6 anyway, so I’d be tunnelling it, which adds another layer of things that can break. When IPv6 becomes necessary rather than optional, I’ll add it. For now, IPv4-only keeps the configuration clean and the attack surface small.

Why OpenBSD

I could run FreeBSD. I could run Linux with nftables. I could run pfSense or OPNsense, which are essentially FreeBSD with a web GUI bolted on. I’ve used some of these. I chose OpenBSD, and I’d choose it again every time for a firewall.

pf Is the Best Packet Filter

I’m not going to be diplomatic about this. OpenBSD’s pf [2] is the best packet filter available on any open-source operating system. The syntax is readable. The rule evaluation is predictable. The state tracking is solid. You can write a complete firewall ruleset in fifty lines and actually understand what it does six months later when you come back to it at 2am because something is broken.

Compare this to iptables, where a moderately complex ruleset looks like someone typed random flags into a terminal while blindfolded. Or nftables, which is better than iptables but still feels like it was designed by committee. pf’s syntax reads like English:

pass in on em1 from em1:network to any
block in on em0
pass out on em0 nat-to (em0)

You can read that. You know what it does. That matters when security depends on your rules being correct.

Security by Default

OpenBSD’s philosophy is “secure by default.” A fresh install has almost nothing listening. No open ports. No running services you didn’t ask for. The kernel is compiled with stack protectors, ASLR, W^X enforcement, and a dozen other mitigations that are optional or absent on other systems.

pledge(2) and unveil(2) are sandboxing primitives baked into the base system. Most daemons in OpenBSD pledge themselves to only the system calls they need and unveil only the filesystem paths they require. If a daemon gets compromised, the damage it can do is drastically limited. This isn’t a bolt-on security framework. It’s woven into the base system.

Clean, Audited Codebase

The OpenBSD team is famously aggressive about code quality. They audit the entire base system. They’ve found and fixed bugs in code that other projects have shipped unchanged for decades. They ripped out OpenSSL and wrote LibreSSL. They wrote OpenSSH (yes, THE OpenSSH, the one running on basically every server on the internet). They wrote OpenBGPD, OpenNTPD, httpd, relayd.

For a device that sits between my entire home network and the internet, I want the most carefully audited code I can get. OpenBSD is that code.

Release Cadence and Binary Patches

OpenBSD releases twice a year, like clockwork. Security patches are available via syspatch(8), which downloads and applies binary patches without requiring a full rebuild. This is exactly what you want for a firewall: stay current, patch fast, don’t faff about with recompiling the kernel.

The Serial Console: Your Only Friend

Here’s the thing about the APU3D2 that trips people up: it has no video output. No HDMI. No VGA. No DisplayPort. Nothing. The only way to interact with this board is via the serial console on the DB9 port.

This means you need a USB-to-serial adapter and a terminal emulator. On macOS, I use screen:

screen /dev/tty.usbserial-XXXX 115200

On Linux:

screen /dev/ttyUSB0 115200

The baud rate is 115200 8N1. This is set by coreboot [4] and it’s what the OpenBSD installer expects. If you’ve used an ALIX board before, note that the ALIX ran at 38400 with tinyBIOS. Get the baud rate wrong and you’ll see garbage characters, which is a special kind of frustrating when you’re trying to install an operating system.

The serial console is not a limitation. It’s a feature. A router doesn’t need a graphical interface. It doesn’t need a monitor. It needs a serial port for initial setup and emergency access, and SSH for everything else. The serial console uses almost no resources, it works even when the network stack is completely broken, and it can’t be attacked over the network. For a security appliance, that’s exactly the right trade-off.

That said, the first time you boot the APU3D2 and nothing appears on any monitor because there’s nothing to appear on, you do have a brief moment of “oh god, is it broken?” It’s not. Connect the serial cable. Deep breaths.

What’s Coming Next

This post is the foundation. Hardware chosen. Network designed. OS picked. In the rest of this series, I’ll walk through actually building the thing:

  • Part 2: Installing OpenBSD. Serial console install, disk partitioning, initial configuration. The installer is excellent but there are a few gotchas specific to the APU3D2.
  • Part 3: Network Configuration. Setting up the three interfaces, DHCP server, and basic routing. This is where the network topology from this post becomes actual config files.
  • Part 4: pf Firewall Rules. The complete ruleset. NAT, filtering, anti-spoofing, rate limiting. This is the heart of the whole project.
  • Part 5: DNS with Unbound. Running a local recursive resolver. No forwarding, no logging by third parties, DNSSEC validation.
  • Part 6: Maintenance and Monitoring. Keeping it updated, watching the logs, and what to do when things go wrong at inconvenient hours.

Every post will include the actual config files, not sanitised excerpts. If you’re building one of these yourself, you should be able to follow along and end up with a working system.

Right. Hardware and network design done. The APU3D2 is on the shelf, the mSATA SSD is fitted, and the serial cable is plugged in. Next time, we boot the OpenBSD installer and find out how much of this careful planning survives contact with reality.

References

  1. PC Engines ALIX 2D3 product page
  2. OpenBSD PF User’s Guide
  3. PC Engines APU3D2 product page
  4. coreboot project
  5. OpenBSD em(4) driver manual page
  6. Intel i211 Ethernet Controller datasheet (PDF)
  7. RFC 1918: Address Allocation for Private Internets
  8. Docker networking overview
  9. OpenBSD project