Files
mangaBookmark/docs/adr/0006-browser-on-the-home-machine.md
T
sulthan 2a3bb6922d Move the browser off the VPS to its own unit (#46) (#52)
Closes #46 once deployed.

The headless browser leaves the API stack and becomes its own compose unit
(`chrome/docker-compose.yml`) intended for the home machine, reached over the
tailnet. No fallback sidecar is left on the VPS.

The backend needs no code change — `BROWSER_WS_URL` was already the only
coupling. Its default is now empty rather than a pinned Docker IP, so an
unconfigured or unreachable browser degrades exactly as it always has: plain-TLS
libraries unaffected, kagane/novelfull logged and skipped, stored covers still
served.

### What shipped

- `chrome/docker-compose.yml` + `chrome/.env.example` — the browser unit, with
  the CDP port bound to `${BROWSER_BIND_ADDR}` (no default) and the resource
  limits from the epic: 512 MiB / 1 GiB memory+swap, `oom_score_adj 800`,
  halved CPU weight, shm 1 GiB -> 128 MiB.
- API stack drops the service, its `depends_on` and the `browser` network.
- `bookmark-api` gains the `default` network. Dropping `browser` had left it on
  `db` alone, which is `internal: true` — no published port and, worse, no
  egress for the poller at all. Caught by actually bringing the stack up.
- ADR-0006 for the topology; `DEPLOY.md` §7 for first-time setup of the browser
  machine; `REDEPLOY.md` §8 for its independent update cadence; architecture
  diagrams, config tables and troubleshooting rows across README/AGENTS/env.

### Verified locally

- Browser unit builds and runs: Chrome 151, UA carries no `HeadlessChrome`,
  all limits applied as declared.
- **Live smoke passes through the new unit**: `TestSmokeKaganeImage` fetched
  56710 bytes of `image/webp`, `TestSmokeKaganeGet` got a 200 with a real
  chapter list. The challenge cleared under the reduced 128 MiB shm.
- Bind isolation proven: refused on the host's non-loopback address, accepted
  on the configured one.
- 321 MiB peak of the 512 MiB cap after a full solve; 0 restarts, no OOM kill.
- API stack comes up clean, `/healthz` 200; egress confirmed present on
  `default` and absent on `db`.
- `go test ./...`, `go vet`, `gofmt` clean.

### Left to the operator

Provisioning the home machine, the Tailscale ACL, setting `BROWSER_WS_URL` in
production, and observing acceptance criteria 5-7 (covers with the machine off,
several days of zero OOM/restarts, VPS memory improvement). `DEPLOY.md` §7 now
carries the before/after `free -m` reading those need.

Reviewed-on: #52
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-09 15:28:21 +07:00

3.8 KiB

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 Cloudflare scores better than a datacenter IP. Both machines were already on the tailnet.

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.