Rural Broadband, Part 2: Building an IT Department for a House I Don't Live In


A small flat-vector house with a glowing single-board-computer icon floating above the roofline, two thin connection lines trailing in from the edge of the frame

This is part 2 of a three-part series. Part 1: The Rabbit Hole covers the investigation that led here, Part 3: The Bill for Letting AI Watch the House covers what happened once this actually ran for real.

Part one was the investigation: what my parents' Indian ISP actually is, what it costs to run one, and the turf politics underneath the whole industry. This part is what I actually did about it: turn a Raspberry Pi that was already sitting in that house into something closer to an IT department, one that has to work without me, without anyone technical nearby, and without trusting the network it lives on.

The stack that was already there

The Pi, a 4GB Raspberry Pi 4B, already on my Tailscale mesh, wasn't idle. It was already running Prometheus, Grafana, and openspeedtest for the house, sitting behind a TP-Link Archer doing PPPoE, with an eero mesh in bridge mode handling WiFi. That last detail matters: the eero used to terminate PPPoE directly, until it stopped being able to, which is why the Archer got introduced in the first place. eero's PPPoE setup exposes username and password, full stop, with no field for the PPPoE Service-Name tag. The Archer's config, by contrast, has Service-Name explicitly set to a specific value matching the ISP's own naming convention. Service-Name is a functional part of the PPPoE discovery handshake: an access concentrator serving multiple profiles can use it to route a connection to the correct one. If My Cable Guy's concentrator needs that tag to match, an eero sending an empty one could get silently rejected or routed to the wrong profile, with credentials that are otherwise completely correct. Whether that's the actual cause or whether it's a MAC-binding issue on the ISP's subscriber management (also plausible, also untestable without more digging) is still open, but it's a different, more specific question than "eero broke somehow."

DNS setup, and a blocked resolver address

The quick win came first: standing up AdGuard Home in Docker on the Pi, upstream to Quad9's secured resolver over both DoH and DoT. Verified end to end: doubleclick.net resolves to 0.0.0.0, example.com resolves normally, and because the Archer's DHCP hands this out to the whole LAN, it covers every device in the house with no per-device setup.

Testing that upstream turned up something I wasn't expecting: 1.1.1.1 is dead on this connection. Not slow. Dead. DNS over UDP times out. DNS over TCP times out. DoH over HTTPS to that exact IP fails instantly. And yet ping to the same address works fine, and the immediately neighboring Cloudflare addresses, 1.0.0.1, 1.1.1.2, 1.1.1.3, all resolve normally. That's not a routing accident, it's a specific, deliberate block of one address while its neighbors work fine, worth knowing before picking a downstream ad-blocking DNS provider since it ruled out anything hard-coded to that specific address.

Design constraint: no bridge to my own network

The harder problem: how do you build something that watches this house and tells me when something's wrong, when nobody there can log into a session and there's no one technical to call?

The obvious answer, run a checker on my own always-on homelab node and have it reach into the Pi over Tailscale, was the wrong one, and not for a subtle reason. My parents' network is not my network. It's an elderly household; I have no idea what else is connected to that LAN, and I don't want to find out the hard way. So the entire design had to satisfy one non-negotiable constraint: zero trust bridge between my own homelab and this network, in either direction. No SSH reaching in. No credential that, if the Pi or its LAN were ever compromised, leads anywhere back to infrastructure I actually care about.

That single constraint reshaped the whole architecture. The agent couldn't live centrally and reach out. It had to live entirely on the Pi, self-contained, and only ever call outward, to public, third-party endpoints that have no relationship to my own infrastructure at all: ntfy.sh for instant push alerts, Healthchecks.io as a dead-man's-switch (the Pi pings it on a schedule; if it goes silent, Healthchecks.io is the one that tells me, since a Pi that can't report can't be trusted to report its own silence), and a dedicated credential on the same SMTP relay my main cluster's alerting already uses, but as an independent credential rather than a shared one. Using the same relay from two networks doesn't bridge them. Using the same credential would.

Authentication: API key, not OAuth token

Running an unattended Claude Code agent means it needs to authenticate somehow, and the obvious path, the same OAuth token tied to my personal subscription that I use everywhere else, was the wrong choice for a box sitting in a network I've explicitly decided not to trust. A stolen subscription token has no spend ceiling; it authenticates as me, indefinitely, until I notice and revoke it. So instead: a separate, spend-capped Anthropic API key, scoped to its own workspace with its own monthly limit. If that box or its network is ever compromised and the key leaks, the damage has an actual number attached to it, decided in advance, not an open-ended draw against my main account. It also solves a second problem: the OAuth token expires yearly and needs an interactive re-login I won't be there to do, and a plain API key doesn't have that failure mode.

TR-069 and GenieACS

The Archer also exposes TR-069 (CWMP), the protocol ISPs normally use to remotely manage a customer's router. The ACS URL field was blank, meaning My Cable Guy isn't using it at all right now, so nothing was listening on the other end. Standing up my own ACS (GenieACS, self-hosted, Dockerized right there on the Pi) turns that from an unused feature into a structured, protocol-native source of exactly the data that's otherwise invisible from the Pi's own vantage point: most importantly, the PPPoE session identity, the same AC-Name field that originally revealed My Cable Guy's existence, now readable programmatically instead of by manually reading a router log after the fact.

I deliberately kept its scope narrow, though. TR-069 could also do ping, traceroute, and throughput diagnostics, but the Pi can already do all of that directly, more simply, without routing it through a protocol that's also, by design, a two-way management channel (an ACS doesn't just get to read a router's state, it can set it). GenieACS's job is specifically "tell us the one thing nothing else can see," PPPoE/franchise identity and WAN-link facts, not "replace the simple stuff." Everything else, uptime, latency, route stability, DNS correctness, throughput, stays as plain, direct Linux commands running on the Pi itself: blackbox_exporter for ICMP/DNS/HTTPS probes, and a handful of small cron scripts writing straight into Prometheus's textfile collector for the things no off-the-shelf exporter covers: hashing the current route to catch a silent path change, watching the public IP's ASN in case the upstream itself ever changes, and sampling real WAN throughput hourly, finally turning today's one-off manual speed tests into an actual time series that can answer whether that upload cap is constant or tied to time of day.

Picking exporters needed a decision too: the community AdGuard-Home-to-Prometheus exporter ecosystem turned out to be fragmented across half a dozen forks with different default ports and unclear maintenance. Rather than pin a dependency I couldn't verify, I wrote 80 lines of Python against AdGuard's own documented /control/stats API instead. One less thing that can quietly stop being maintained out from under a system nobody's there to fix by hand.

Memory budget on the Pi

None of this is free, and a 4GB Pi already running four containers isn't a bottomless resource. GenieACS turned out to be less than I first assumed: one consolidated container bundling all four of its services, not four separate ones. It's paired with MongoDB, though, and MongoDB's default cache sizing tries to grab roughly half of whatever RAM isn't already spoken for. Left at default on a shared box, that would quietly starve everything else running alongside it. Every container on the box, old and new, got an explicit memory ceiling instead: MongoDB capped at 256MB, GenieACS at 200MB, the exporters at 64MB each, the existing Prometheus/Grafana/AdGuard stack capped too, even though they'd been running unconstrained the whole time. Summed, the whole stack lands under a gigabyte of steady-state memory against 3.7GB usable, comfortable headroom, but only because the caps are actually written into the compose file instead of left to each tool's own judgment about how much it deserves.

Auto-update vs. pinning, and where the LLM fits

The design leans on one more decision I didn't expect to make: auto-update, not version pinning. Pinning is the safer default anywhere I can physically walk over and fix a broken update. It isn't safer here, where "broken" could mean the house's actual DNS server silently failing with nobody around who'd know to restart a container. The trade I ended up making instead: keep everything on :latest, and put the responsibility explicitly on the daily agent, not just "is everything up" but "did anything's image change recently, and did its behavior change right after." That's not a side effect of a generic audit prompt; it has to be an instruction I actually write into the agent's task, or the whole safety margin of choosing convenience over caution quietly disappears.

Deterministic thresholds (Pi unreachable, AdGuard down, disk past 90%, upload below some floor for fifteen straight minutes) don't need an LLM in the loop at all. Grafana's own alerting handles those instantly, wired straight to ntfy and email. The daily Claude-run digest is reserved for the part that's actually hard to hard-code: reading the route-change log, the ASN history, whatever GenieACS reports about the PPPoE session, and saying what's actually going on. Same judgment-over-checklist principle behind watchtower, the equivalent system I'd already designed for my main cluster, just re-derived here under a much stricter trust boundary.

Current status

As of this writing, the whole stack is staged, not deployed: a complete docker-compose.yml with every limit applied, the Prometheus scrape config, the four cron scripts, all committed and ready to push the next time I'm physically at that network. What's left isn't technical, it's administrative: an account on Healthchecks.io, a spend-capped API key from the Anthropic console, and a dedicated credential on that same SMTP relay, blocked behind opening a firewall on a box I haven't gotten to yet.