## Why Treat a Home Network Like Production?
Most home networks are an accident. You get a gateway from your ISP, you plug things in, and the defaults decide your security posture for you. That's fine right up until it isn't — until a $30 smart plug with a firmware team of unknown size is sitting on the same flat network as the laptop you do your banking on, free to talk to it.
I do cloud and product security for a living, so I spend my days thinking about blast radius, least privilege, and the gap between "it works" and "it's safe." At some point it started to bother me that my own house didn't get the same treatment. So I rebuilt it — deliberately, and with the same habits I'd bring to infrastructure I get paid to defend. Not because my living room is a threat model worthy of a SOC, but because the principles scale down surprisingly well, and the exercise sharpens the muscles I use professionally.
The foundation is boring on purpose. I put the ISP's gateway into true bridge mode, so it stops being a router and becomes a dumb pipe. That makes my own router — a prosumer box running an OpenWrt-based firmware — the single router on the public IP. One NAT, not two. One firewall to reason about, not a confusing double layer where you're never sure which box is dropping a packet. The WAN side is default-deny (input DROP): nothing from the internet gets in unless I've explicitly and deliberately allowed it, and I've allowed almost nothing.
That's the whole thesis in one sentence: a prosumer router with a real Linux userland underneath it can be driven like code, and once it can, everything else — segmentation, DNS, remote access — becomes a reviewable, reversible change instead of a series of clicks I'll never remember.
## Infrastructure as Code, for a Router
The phrase "network as code" gets thrown around loosely, so let me be concrete about what I actually mean. Most changes to the router are small scripts that talk to it over SSH and drive OpenWrt's configuration system, UCI — mostly shell, with Python where the logic gets fiddlier. The scripts follow five rules, and the five rules are the entire point:
Dry-run by default. A script prints what it would do and changes nothing unless I pass --apply. It's astonishing how many bad changes never happen simply because you read the dry-run first.
Snapshot before you mutate. Anything that changes state takes a full config backup first, so "undo" is always one restore away.
Verify, and roll back automatically on failure. A disruptive change arms a health check; if the network doesn't come back the way it should, the script restores the snapshot on its own rather than leaving me locked out of my own router.
Secrets never live in the repo. The SSH key is dedicated to this job, credentials are referenced by name from the OS keychain, and a secret-scanner runs before every commit so a key can't slip in by accident.
Lint and scan — matched to the language, not to one favorite tool. Linting and security scanning are different jobs: one catches bugs and sloppy style, the other flags genuinely unsafe patterns. So each script gets the checks that fit it — ShellCheck for the shell ones; Ruff for Python style and correctness, and Bandit for Python security. A pre-commit hook runs the lot, so "I forgot to check" stops being a failure mode. None of it is exotic — it's ordinary code-review hygiene, pointed at my own house.
The skeleton of one of these scripts looks like this. It's deliberately unglamorous; the value is in the shape, not the cleverness:
#!/usr/bin/env bash
set -euo pipefail
# apply-vlan-forwarding.sh — idempotent; DRY-RUN by default, --apply to commit.
ROUTER="${ROUTER:?set ROUTER (host from ~/.ssh/config)}"
APPLY="${1:-}"
run() {
if [[ "$APPLY" == "--apply" ]]; then
ssh "$ROUTER" "$1"
else
echo "DRY-RUN: $1"
fi
}
# 1. Snapshot the full config before any mutation.
run "sysupgrade -b /tmp/cfg-backup-\$(date +%s).tar.gz"
# 2. Make the change (see the segmentation section for what this does).
run "uci add firewall forwarding"
run "uci set firewall.@forwarding[-1].src='trusted'"
run "uci set firewall.@forwarding[-1].dest='iot'"
# 3. Commit and reload only the firewall service.
run "uci commit firewall && /etc/init.d/firewall reload"
Run it with no arguments and you get a transcript of intended commands. Run it with --apply and it does exactly what the transcript said, nothing more. There's no state living only in my head, and there's no "I think I changed that setting last spring" — the scripts are the record.
## The Architecture at a Glance
Before the details, here's the whole shape of it — the trust boundaries and the DNS path, which are the two things that actually carry the security story. Edge styles in the diagram mean something: a thick arrow is allowed traffic, a dotted arrow is traffic that's blocked or isolated.
bridge mode — dumb pipe"] ISP --> WAN subgraph RTR["Prosumer router · OpenWrt — sole router, single-NAT"] direction TB WAN["WAN zone
input = DROP (default-deny)"] FW["Firewall zones +
inter-VLAN policy"] AGH["AdGuard Home
LAN resolver · intercepts :53"] REF["Avahi mDNS reflector
Trusted ↔ IoT only"] TS["Tailscale
remote access (subnet router)"] WAN --> FW end FW --> TRUST["🔒 Trusted
10.10.10.0/24
laptops · phones"] FW --> IOT["📷 IoT
10.20.20.0/24
cameras · plugs · TV · speakers"] FW --> GUEST["👥 Guest
10.30.30.0/24
internet-only"] %% Asymmetric inter-VLAN policy (the security core) TRUST ==>|"allow: cast / control"| IOT IOT -.->|"blocked"| TRUST GUEST -.->|"isolated"| TRUST GUEST -.->|"isolated"| IOT %% Per-client encrypted DNS TRUST ==>|":53 redirect"| AGH IOT ==>|":53 redirect"| AGH GUEST ==>|":53 redirect"| AGH AGH ==>|"DoQ · per-client profile"| NDNS["☁️ NextDNS
per-client filtering"] IOT -.->|"DoH/DoT egress blocked"| BLK["🚫 resolver bypass denied"] GUEST -.->|"DoH/DoT egress blocked"| BLK classDef trust fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px; classDef iot fill:#fff3e0,stroke:#e65100,stroke-width:2px; classDef guest fill:#eceff1,stroke:#455a64,stroke-width:2px; classDef block fill:#ffebee,stroke:#b71c1c,stroke-width:2px; classDef ext fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px; class TRUST trust; class IOT iot; class GUEST guest; class BLK block; class NET,ISP,NDNS ext;
Diagram, in words:
The internet reaches an ISP gateway running in bridge mode, so an OpenWrt-based prosumer router is the sole router on the public IP. The router's WAN zone is default-deny. On the router, a firewall enforces three firewall-isolated VLANs — Trusted, IoT, and Guest — plus AdGuard Home (the LAN DNS resolver), a scoped Avahi mDNS reflector (Trusted↔IoT only), and Tailscale for remote access. Inter-VLAN policy is deliberately asymmetric: Trusted devices may reach IoT devices to cast to or control them, but IoT cannot initiate back into Trusted, and Guest is isolated from both. Every VLAN's plaintext DNS is redirected to AdGuard Home, which forwards to NextDNS over DNS-over-QUIC with a per-client profile; IoT and Guest are additionally blocked from DoH/DoT egress so they cannot bypass the resolver.
## Zero-Trust Segmentation, and Why It's Asymmetric
The single most valuable thing you can do to a flat home network is stop it from being flat. I split the network into three firewall-isolated VLANs, using a scheme tidy enough that I never have to look it up: the VLAN ID is the repeated octet in the subnet, and the gateway is always .1.
- Trusted —
10.10.10.0/24— personal laptops and phones. - IoT —
10.20.20.0/24— cameras, smart speakers, plugs, a TV, a printer, a thermostat. - Guest —
10.30.30.0/24— internet-only, isolated from everything else.
Segmentation alone isn't the interesting part, though — the asymmetry is. I want to be able to cast a video to the TV and pull up a camera feed from my phone, which means Trusted needs to reach IoT. But I emphatically do not want a compromised camera — and cheap cameras get compromised — to be able to open a connection back into the network where my real devices live. So the policy is one-way: Trusted → IoT is allowed; IoT → Trusted is denied. Guest can talk to neither.
Here's the detail I like, because it's a small lesson in reading the tool rather than reaching for a bigger hammer. In OpenWrt, zones don't forward traffic to each other by default — a forwarding only exists if you create it. So the asymmetry isn't enforced by an elaborate block rule; it's enforced by the absence of the reverse forwarding. I add exactly one direction and stop:
# Allow Trusted -> IoT so I can cast/control devices.
uci add firewall forwarding
uci set firewall.@forwarding[-1].src='trusted'
uci set firewall.@forwarding[-1].dest='iot'
uci commit firewall
# There is deliberately NO iot -> trusted forwarding.
# The block is the thing I DIDN'T write. Default-deny does the work.
The best security control is often the one you don't have to actively maintain. A DROP rule can be edited, reordered, or accidentally disabled; a forwarding that was never created can't be any of those things. When someone reviews this config, the safety is legible at a glance: there's simply no path in that direction.
## Per-Client, Encrypted DNS
DNS is the most underrated control in a home network. Nearly everything a device does starts with a name lookup, which means the resolver sees intent before any connection is made — and can shape it. My setup runs AdGuard Home on the router as the LAN resolver, and forwards upstream to NextDNS over DNS-over-QUIC (DoQ, standardized in RFC 9250 on port 853). The queries leaving my network are encrypted; the resolution happening inside it is filtered per device.
Two design choices make this more than "install AdGuard and move on."
Devices can't opt out of the resolver. A firewall redirect transparently DNATs every VLAN's outbound plaintext :53 to AdGuard Home, so a gadget with a hardcoded 8.8.8.8 gets quietly answered by my resolver instead of Google's. That covers plaintext, but the modern bypass is encrypted DNS — a device shipping its own DoH or DoT resolver to route around me. DoT and DoQ both live on port 853, so those I simply reject at the edge for the untrusted segments:
# Force all plaintext DNS to AdGuard Home (per VLAN zone).
uci add firewall redirect
uci set firewall.@redirect[-1].name='Intercept-DNS-IoT'
uci set firewall.@redirect[-1].src='iot'
uci set firewall.@redirect[-1].proto='tcp udp'
uci set firewall.@redirect[-1].src_dport='53'
uci set firewall.@redirect[-1].dest='iot'
uci set firewall.@redirect[-1].dest_port='53'
uci set firewall.@redirect[-1].target='DNAT'
# Deny DoT/DoQ egress (port 853) for IoT so devices can't self-encrypt around me.
uci add firewall rule
uci set firewall.@rule[-1].name='Block-DoT-DoQ-IoT'
uci set firewall.@rule[-1].src='iot'
uci set firewall.@rule[-1].dest='wan'
uci set firewall.@rule[-1].proto='tcp udp'
uci set firewall.@rule[-1].dest_port='853'
uci set firewall.@rule[-1].target='REJECT'
uci commit firewall
DoH is the honest asterisk here: it rides port 443 alongside all your normal HTTPS, so you can't blanket-block it without breaking the web. The realistic mitigation is denying the known public DoH endpoints (maintained blocklists exist for exactly this) plus NextDNS's own bypass-prevention — not a clean port drop. I'd rather say that plainly than imply I've sealed a door that physics leaves slightly ajar.
The dashboard speaks in names, not IPs. AdGuard Home pins client identity by DHCP reservation, and I group clients by device class — all the cameras share one NextDNS profile, all the speakers another — so filtering policy is set per role instead of per gadget. The nice touch is on the encrypted upstream: NextDNS lets you encode a label in the DoQ endpoint hostname, so instead of every query showing up as "the router's IP," the dashboard attributes it to a readable class:
# AdGuard Home upstream, per device-class (DoQ to NextDNS).
# The hostname label before the profile ID is what the dashboard displays.
quic://cameras-<profile-id>.dns.nextdns.io
quic://speakers-<profile-id>.dns.nextdns.io
Now when I look at the query log and see a camera reaching for a domain it has no business touching, I know which class of device it was without cross-referencing a spreadsheet of MAC addresses. Observability is a security feature; making the logs legible is part of the job.
## Remote Access Without an Extra Front Door
I used to run a self-hosted WireGuard server on the router for getting back into the Trusted network from the road. It worked, and WireGuard is excellent. But it also meant an inbound port open on my public IP — one more thing to patch, monitor, and reason about. I retired it in favor of Tailscale, which builds its mesh with outbound connections and NAT traversal, so there's no listening port exposed to the internet at all. That's a smaller attack surface for a home setup, and less of my time spent being a VPN administrator. It's a pragmatic trade, not a religious one — WireGuard is still the engine underneath Tailscale.
The router runs as a Tailscale subnet router, advertising the Trusted subnet so my laptop and phone can reach home devices without installing a client on each one:
# On the router: advertise the Trusted subnet to my tailnet.
tailscale up --advertise-routes=10.10.10.0/24
And a war story worth keeping, because it's a textbook signature. When I first turned on full-tunnel mode from a client, everything hung — DNS resolved, small requests worked, anything substantial blackholed. "Split tunnel works, full tunnel doesn't" is the tell of a path-MTU problem: the encapsulation overhead pushes packets past what some link on the path will carry, and the fragmentation-needed signal gets swallowed. Setting the client MTU to 1280 (the IPv6 minimum, and a safe floor for tunnels) fixed it instantly. I chased the symptom for a while before I recognized the shape of it — a good reminder that the fix is usually cheaper once you name the pattern.
## Where Consumer Hardware Fights Back
This is the part I find most worth writing about, because it's where judgment actually lives. Anyone can follow a tutorial when the hardware cooperates. The skill is recognizing when it won't, naming the limit honestly, and engineering the best mitigation the platform allows anyway. Three examples.
A consumer smart-switch can't isolate its own management plane. I added a managed switch to hand out VLAN tags to wired devices. The catch: its admin interface rides every VLAN, and it grabbed a DHCP lease from whichever server answered first — which put its management address on the IoT segment. That's the worst possible place for it, because the router's firewall can't filter traffic within a subnet; every IoT device could reach the switch's admin plane at layer 2, and no rule I write on the router would ever see those packets. There is no setting on this class of switch that fully isolates management. So I did the next-best thing: pinned the switch to a static management IP on the Trusted subnet, which forces any cross-VLAN attempt to reach it to traverse the router — where the IoT → Trusted block stops it cold. It's not true isolation, and I won't call it that. It's the strongest mitigation the hardware permits, and knowing the difference is the point.
A vendor feature that structurally couldn't do what its label promised. The router firmware ships a per-device VPN-policy feature — route this device through a VPN, leave the rest alone. Exactly what I wanted for one device on a custom VLAN. It silently didn't work. Rather than assume I'd misconfigured it, I read the script that actually implements the feature, and found it was hardwired to the default LAN bridge — it had no concept of a device living on a VLAN I'd created. The button existed; the engine behind it didn't cover my case. The lesson is one I keep relearning: verify the mechanism, don't trust that a control does what its name implies. A UI toggle is a claim, not a guarantee.
Decode the payload — a logged "answer" can be a block. A smart speaker and a couple of cameras connected to Wi-Fi but didn't work. The DNS query log looked healthy: the domains were being answered. It would have been easy to rule DNS out and go hunting elsewhere. Instead I decoded the actual response bytes — and the answer was 0.0.0.0. A privacy blocklist was null-routing the device-cloud domains those gadgets depend on, and a null-route looks identical to a real resolution if you only check that a response came back.
$ dig +short @10.20.20.1 device-api.example-cloud.com
0.0.0.0
# "Got an answer" is not "got a real answer." 0.0.0.0 is a silent block.
# Parse the value, don't infer success from the presence of a field.
That habit — decode the payload, don't trust its shape — has saved me more hours in production than almost any other. A 200 response can wrap an error. An exit code of zero can hide a failure. And a DNS answer can be a wall. It's the same reason the vendor dashboard isn't my source of truth: it doesn't even render devices on my custom VLANs (they show as "offline"), so I read the layer beneath it instead — the association list, DHCP leases, the ARP/neighbor table, connection-tracking. "Applied" and "confirmed" are different claims, and conflating them is how you talk yourself into believing a control is in place when it isn't.
## A Hardening Pass, Prompted by a Real Advisory
In July 2026, CISA, the NSA, the FBI, and international partners published a joint advisory on router hygiene — AA26-194A, "Improve Router Hygiene to Protect Against Russian State-Sponsored Targeting" (published July 13, 2026). It documents an FSB Center 16 actor that industry tracks as "Static Tundra," which goes after poorly configured routers — largely via SNMP with default or weak community strings, then exfiltrating configuration over TFTP. It's an enterprise- and Cisco-focused advisory, but a good advisory is also a checklist, so I audited my setup against it.
The honest result is a little anticlimactic, and that's the good news: a well-segmented network was already mostly aligned. No SNMP agent was running. No telnet, no TFTP. WAN was default-deny, and management was reachable only from the Trusted segment. The value wasn't in a dramatic remediation — it was in methodically verifying each item rather than assuming I was fine because I'd been careful. Where I did tighten: I moved SSH to key-only (password authentication disabled), removed a couple of stale inbound allow rules that no longer had a reason to exist, and made sure the resolver wasn't binding the public interface. Small, cheap, and exactly the kind of thing that rots if you never re-check it.
The meta-lesson generalizes past this one advisory. Alignment with a standard isn't a state you reach and keep; it's a thing you re-confirm, ideally with a script, on a schedule. An audit that ends in "yep, still good" is not wasted work — it's the work.
## What It Bought Me — and What I'd Do Differently
So what did all this actually buy a house? Not invulnerability — I want to be careful not to oversell a home network. What it bought me is a network I can reason about. A compromised camera can't pivot to my laptop. A guest device can't see anything but the internet. Every device's DNS is filtered and encrypted, and nothing can quietly route around that. And when something breaks, I have scripts that describe the intended state, backups that let me undo, and lower-level signals that tell me the truth instead of a dashboard that guesses. That legibility is the real product.
What would I do differently? I'd buy the managed switch after reading how it handles its own management plane, not before — that limitation cost me an afternoon and a compromise I'd rather not have made. And I'd resist the urge to add controls that only look like protection: a rule I have to remember to maintain is weaker than a path that structurally doesn't exist. The whole project pushed me back toward defaults that deny, policies that are legible, and verification that doesn't rely on my memory.
The through-line to my day job is the part I didn't expect. The habits that make this home network trustworthy — dry-run before apply, snapshot before mutate, decode the payload, "applied" is not "confirmed," name the limit instead of papering over it — are the exact same habits that keep production infrastructure honest. Practicing them somewhere the stakes are low, on hardware that fights back, turns out to be excellent training for the places where the stakes are high.
One note in the interest of full disclosure: like most things I build these days, this was done with heavy use of AI-assisted tooling — a workflow I've written about separately and honestly. I directed it, decided the architecture, and verified every change myself. The judgment is mine; the typing had help.
Curious how I keep AI-assisted work from quietly introducing the very gaps this post is about? That's the subject of The Security Side of Vibe Coding.
Donny Schreiber
Cloud and product security engineer at AWS Professional Services, based in Boulder, Colorado. I write about cloud security, DevSecOps, infrastructure-as-code, and the security side of AI — drawn from daily practice.
The Security Side of Vibe Coding
An honest look at what AI-generated code gets wrong — and the guardrails that catch it — from someone who uses these tools daily.
Enterprise-Grade AWS VPC IPAM with Terraform
Hierarchical, multi-Region IP address management automated end-to-end in Terraform — the same segmentation instinct, at cloud scale.