This is one of the engineering war stories behind the Mistborn case study, the open-source private-cloud platform I built and maintained solo from 2020 to 2024.
WireGuard can support different policy for peers on one interface. AllowedIPs associates tunnel addresses with a peer, and the firewall can match those addresses. What WireGuard does not provide is a complete per-device policy lifecycle for the application managing it. Mistborn v1 needed to create, suspend, and delete a device’s routes and firewall state as one unit while its policy model was still changing.
One client, one server instance
Mistborn’s answer was a one-to-one mapping: every client profile got its own server-side WireGuard instance, listening on its own randomly assigned UDP port. The Django control plane generated both halves of the pair, handed the client side to the user as a config file or QR code, and brought the server side up as its own interface.
That sounds wasteful until you look at what it buys. Each interface is now a policy boundary with a name. The kernel routes per interface, iptables matches per interface, and tearing down one client cannot disturb another. WireGuard interfaces are kernel objects rather than one userspace process per peer. At Mistborn’s scale, their resource cost was not the limiting factor; router configuration and policy complexity were.
The per-client policy itself rode along in the generated config as PostUp and PostDown directives. When an interface came up, its rules came up; when the profile was deleted, PostDown removed exactly what PostUp added. The control plane never had to reconcile a global firewall state, because every rule had an owner with a lifecycle.
[Interface]
Address = 192.0.2.2/32
ListenPort = 51820
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE(Illustrative rules; the generated set varied with profile type. The address is from the IPv4 documentation range.)
Client-to-client communication followed the same pattern. By default, clients could reach shared services on the host (Nextcloud, Syncthing, Jitsi) but not each other. Toggling “client-to-client” in the UI just added or removed the forwarding rules between the relevant interfaces. The feature was a checkbox precisely because the architecture made it one.
Docker will undo your firewall, explicitly
A deny-by-default INPUT policy does not protect a published Docker port, and nobody warns you about it. Docker adds destination NAT and forwarding rules; traffic for the container is routed through FORWARD, not delivered to a local socket through INPUT. You can set INPUT DROP and still expose an “internal” web UI through a Docker port publication.
Mistborn handled this by refusing to share custody. UFW, if present, was disabled. The platform manipulated iptables directly, saved a minimal deny-by-default ruleset with iptables-persistent (INPUT and FORWARD policies DROP, allow ESTABLISHED and RELATED), and then, every time it brought up a container with a published port, added an explicit rule blocking external traffic to that port. Internal traffic arriving over a WireGuard tunnel still passed; the internet got nothing.
Designing the Mistborn chains to coexist with Docker’s had a second benefit: a power cycle always came back to a working, secure state. The persistent baseline rules loaded before Docker started, Docker added its chains, and Mistborn inserted its filtering where forwarded container traffic would encounter it. On current Docker installations, DOCKER-USER is the documented place for user policy that must run before Docker’s accept rules. The exact chain order should always be checked with iptables -S and iptables -t nat -S, not inferred from an INPUT policy.
Logging the noise without drowning in it
A DROP policy silently eats unsolicited traffic, which is secure but invisible. Mistborn redirected to-be-dropped packets through a dedicated chain that logged packet metadata first, then dropped. A summary of that log fed the Metrics page in the UI, so users could watch the internet’s background radiation bouncing off their firewall. The active WireGuard listening ports were deliberately excluded from logging: WireGuard does not respond to unauthenticated probes, and logging them would only generate noise.
What the v2 rewrite changed
The per-client-instance model was right for v1: it made per-device policy trivial while I was still discovering what the policy model needed to be. But it had a real cost. Dozens of random listening ports meant that putting Mistborn behind a home router required forwarding a moving target, and that was exactly where much of the audience wanted to run it.
By the time I rewrote it for v2.1, the policy machinery had matured enough that new clients could share a single listening port without giving up per-client rules, and router port forwarding became a one-line setup. I took one lesson with me: choose the architecture that makes your current unknowns cheap to explore, and consolidate once they’re known. The expensive version taught me what the cheap version needed to preserve.
For the wider story, including what this platform was and where the thinking went next, read the Mistborn case study.