# ADR-0006: The browser runs on the home machine, over the tailnet Date: 2026-08-09 Status: accepted ## Decision The headless browser is no longer part of the API stack. It is its own compose unit (`chrome/docker-compose.yml`), deployed on the home machine, and the API on the VPS reaches it over the existing tailnet through `BROWSER_WS_URL`. No fallback sidecar remains on the VPS. The backend needs no code change for this. The CDP endpoint was already a configuration seam and the fetcher only ever holds the endpoint URL, so relocation โ€” and reversal โ€” is one environment variable. ## Why The sidecar held 471 MiB working set (645 MiB peak) on a 1974 MiB VPS with no swap, which also hosts Traefik, Gitea and its Postgres. That is 24% of the host and 86% of this project's memory, for a service that at the time answered zero requests: the poller's due query joins bookmarks, production held four kagane series and no bookmarks on any of them, and with no kagane bookmark the web UI never rendered a kagane cover either. The home machine has 5.9 GiB of swap and a residential egress, which avoids the cloud-hosting-IP signature Cloudflare's Bot Fight Mode documentedly challenges. Both machines were already on the tailnet. Corrected 2026-08-12: the original wording said Cloudflare "scores" a residential egress better than a datacenter IP. There is no score on a free-plan zone; what is documented is signature matching, and hosting-provider IP space is one of the signatures (`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`). Memory was the load-bearing reason regardless. This move is only safe because covers are persisted (ADR-0005's sibling work, issue #43/#45) and the browser is on-demand (ADR-0005). Without stored covers a sleeping home machine would blank the library; without on-demand start the CI runner that already holds ~1.2 GiB of that box's 1.8 GiB would be squeezed around the clock. ## Constraints **`BROWSER_WS_URL` must be the tailnet IP, never a MagicDNS hostname.** Chrome's DevTools HTTP handler answers `/json/version` with a 500 for any `Host` header that is not an IP or `localhost`. This is the same trap that previously forced a pinned Docker IP; the pinned subnet is gone, the constraint is not. **The CDP port binds to the tailnet address only, never `0.0.0.0`.** CDP authenticates nothing: whatever reaches the port drives the browser and, through it, the host. On the VPS the safety came from Docker network membership; the home machine has a real LAN, so a `0.0.0.0` bind is a hole punched into it. The bind address is the enforcement and Tailscale device identity plus a per-device ACL is the policy. `BROWSER_BIND_ADDR` deliberately has no default, so an unset value fails the deploy instead of publishing CDP to the LAN. No bearer-token proxy is added in front of CDP. It would only defend against a device already inside the tailnet, and it would be one more thing between the poller and a browser that is already hard enough to keep clearing challenges. **Resource limits are load-bearing, not decorative.** The browser is the newcomer on that box, not the incumbent. A hard 512 MiB cap with 1 GiB memory+swap makes Chrome reclaim its own cold pages onto the machine's SATA swap instead of taking resident memory from the runner; untuned Chrome peaked at 645 MiB cgroup, which is more than is free there. `oom_score_adj` biases the kernel to kill the browser first and never CI. Reduced CPU weight makes a challenge solve yield to a running build โ€” cold start degrades to about 3 s at half a CPU, immaterial against a 45-second challenge budget. The shared-memory reservation drops from 1 GiB to 128 MiB against a measured 19 MiB peak. ## Consequences An unreachable browser degrades exactly as an unset `BROWSER_WS_URL` already does: plain-TLS libraries are unaffected, kagane and novelfull log and skip, the series waits out its cooldown, and stored covers keep serving. A power outage at home costs chapter freshness on two sites, never the appearance of the library. The two units are deployed and updated independently. `REDEPLOY.md` ยง8 covers the browser; everything before it covers the API stack. A local `docker compose up` now brings up two services, not three, and polls kagane only if `BROWSER_WS_URL` is pointed somewhere.