Move the browser off the VPS to its own unit (#46) #52

Merged
sulthan merged 2 commits from feat/46-remote-browser into main 2026-08-09 15:28:22 +07:00
Owner

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.

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.
sulthan added 2 commits 2026-08-09 15:24:46 +07:00
The headless browser leaves the API stack. It becomes its own compose unit
(chrome/docker-compose.yml) deployed on the home machine and reached over the
tailnet, returning 471 MiB of working set to a 1974 MiB VPS that has no swap.
No fallback sidecar is left behind.

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

The browser unit publishes CDP on ${BROWSER_BIND_ADDR} with no default, because
CDP authenticates nothing and the home machine has a real LAN: an unset value
must fail the deploy rather than silently expose an endpoint that is remote code
execution for anything that reaches it. Resource limits are sized against the
measured 645 MiB untuned peak and the CI runner that already holds 1.2 GiB of
that box.

bookmark-api gains the default network. Dropping `browser` left it on `db`
alone, which is internal: true — that meant no published port and, worse, no
egress for the poller at all. Caught by bringing the stack up.

Docs: ADR-0006 for the topology, DEPLOY.md §7 for first-time setup of the
browser machine, REDEPLOY.md §8 for its independent update cadence, plus the
architecture diagrams, config tables and troubleshooting rows.
Compose merges `networks:` across override files rather than replacing them,
so the prod override's claim that it must re-name every network was false —
and the rationale built on it ("proxy carries the poller's egress") was false
too. Verified against `docker compose config`: the API renders on db, default
and proxy with only `proxy` named here. Egress comes from `default`, which is
now the thing a maintainer must not tidy away.

chrome/.env.example shipped BROWSER_BIND_ADDR=100.x.y.z as a live value, so
`cp .env.example .env && docker compose up` failed with Docker rejecting an
invalid IP instead of the guard message both troubleshooting tables promise.
Commented out, so the promised message is what you actually get.

DEPLOY §7 gains the two steps that were asserted but never instructed: a
Tailscale ACL, without which "Tailscale identity is the access control" is
aspirational and 9222 is open to every device on the tailnet; and a VPS
`free -m` reading before and after, without which the memory this move
reclaims cannot be shown.

Also drops a change-narration comment and three restatements of measured facts
that already have a canonical home.
sulthan merged commit 2a3bb6922d into main 2026-08-09 15:28:22 +07:00
Sign in to join this conversation.