I run browser automation inside a disposable VM
Browser automation on the homelab holds real logins, so it runs inside a disposable VM. That VM stops one kind of compromise and does nothing for another, and the design only works because those two guarantees are enforced at two different layers, not one.
Automating a browser that holds real logins means running code that fetches arbitrary pages on the open internet. That code needs to be treated as something that can be compromised. That’s the assumption behind every decision here.
Why a VM instead of a sandboxed process
The browser automation on this network runs inside a disposable virtual machine, launched on demand and torn down after each job. The reason isn’t abstract: Chromium’s rendering engine assumes 4 KB memory pages, and the Pi it needed to run on uses a kernel built with 16 KB pages, so navigation hung outright on the host. The direct fix, rebuilding the Pi’s kernel with 4 KB pages, broke the Pi’s Ethernet and took the home network’s DNS down with it. That ruled out patching the host to match the browser.
Running the browser inside its own small guest, with its own kernel built the way Chromium expects, sidesteps the page-size problem entirely and never touches the host kernel, its network stack, or the DNS resolver running on that same box. The guest costs about 2 GB of memory while it’s running and a few tens of seconds to boot cold, so it stays on-demand rather than always-on.
Placement: the always-on box, not the one running a model
This automation runs on the Raspberry Pi, not the GPU box. The GPU box exists to hold a model in memory, and a browser process, headless or not, competes for the same pool that model needs. The Pi already carries DNS and everything else that has to be up all the time, and a browser job launched on demand under its own memory cap costs that box far less than fighting a model for the same 8 GB would cost the other one. Same reasoning that put DNS on the low-power box in the first place: a job goes where it costs the least to be wrong about.
What the VM guarantees, and what it doesn’t
A VM boundary is real protection against one specific thing: if the browser inside it gets exploited at the kernel level, the compromise is stuck inside that guest. It is not, by itself, protection against the compromised guest reaching other things on the network. The guest’s outbound traffic gets network-address-translated through the host, and network address translation is a routing detail, not a security boundary, so a compromised browser inside the VM can still open a connection to anything else on the LAN unless something stops it.
The fix is a firewall rule on the host itself, not inside the guest, keyed to the dedicated user account the guest’s process runs as. It denies that uid’s traffic to the local network ranges and allows everything else, loaded at boot and reasserted every time a guest starts, so it holds regardless of what happens inside the VM. Enforcing it on the host rather than trusting the guest to police itself is the whole point: a compromised guest has no way to touch a rule it was never allowed to see.
Two identities, not one guest with two exits
Some jobs need to browse anonymously; some need a real logged-in session. The obvious design puts both behind one guest with a switchable exit, direct or through a VPN tunnel. That falls apart at the browser profile: a single cookie jar that sometimes exits from the home connection and sometimes from a rotating VPN address looks exactly like the pattern session-security heuristics are built to catch, and a logged-in profile behind a hopping exit collects CAPTCHAs and forced re-logins until it locks out entirely.
The fix is two separate guests, one per exit mode, each with its own user account, its own disk profile, and its own machine identity, chosen at the start of a job rather than switched mid-run. The VPN guest’s profile starts empty and stays empty on purpose: anything that needs a real login has to run in the direct guest, which is the design working as intended, not a gap in it.
The kill switch blocked its own tunnel
Building the VPN path surfaced a subtler lesson worth keeping. The firewall rule meant to guarantee that guest’s traffic never leaves except through the tunnel was written as: drop everything for that uid except traffic on the tunnel’s own interface. It looked correct, and it passed every test against a tunnel that had never successfully connected.
The moment a real tunnel came up, all of that guest’s traffic stopped dead, tunnel included. WireGuard’s encrypted outer packets inherit the user ID of the socket that generated the data they’re carrying, so they left the host still tagged as the browser guest’s own uid, on the physical network interface rather than the tunnel interface, and the same rule that was supposed to protect the tunnel killed it outright.
The fix tags the tunnel’s own outbound packets with a firewall mark that only a process with elevated network privileges can set, something the confined guest doesn’t have, and lets that mark bypass the block.