Fix review findings from the browser relocation (#46)

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.
This commit is contained in:
2026-08-09 15:24:22 +07:00
parent e4a313e626
commit 15382eb603
6 changed files with 48 additions and 34 deletions
+31 -10
View File
@@ -273,6 +273,17 @@ above.
Do this after §2, on the second machine. Both machines must already be on the Do this after §2, on the second machine. Both machines must already be on the
same tailnet. same tailnet.
First, on the VPS, record what you are reclaiming — this is the whole point of
the move and there is no way to measure it afterwards:
```bash
free -m | awk '/^Mem:/ {print "available before:", $NF, "MiB"}'
```
Take it again after §7 is finished and the old sidecar is gone. Expect roughly
the sidecar's former footprint back (measured at 471 MiB working set, 595 MiB
cgroup).
**On the home machine:** **On the home machine:**
```bash ```bash
@@ -280,7 +291,7 @@ git clone <this repo> ~/mangaBookmark && cd ~/mangaBookmark/chrome
tailscale ip -4 # -> 100.x.y.z, this machine's tailnet IP tailscale ip -4 # -> 100.x.y.z, this machine's tailnet IP
cp .env.example .env cp .env.example .env
sed -i "s|^BROWSER_BIND_ADDR=.*|BROWSER_BIND_ADDR=$(tailscale ip -4)|" .env echo "BROWSER_BIND_ADDR=$(tailscale ip -4)" >> .env
docker compose up -d --build docker compose up -d --build
``` ```
@@ -296,6 +307,21 @@ On the VPS that job was done by Docker network membership; this machine has a
real LAN, so `0.0.0.0` would be a hole punched into your home network. Compose real LAN, so `0.0.0.0` would be a hole punched into your home network. Compose
refuses to start rather than guess. refuses to start rather than guess.
**Narrow it to the one device that needs it.** The bind address keeps CDP off
your LAN; it still leaves port 9222 open to every device on the tailnet, and
CDP has no login. Add a rule in the Tailscale admin console's access controls
so only the VPS can reach it — tag the two machines, then:
```jsonc
// tailnet policy file
"acls": [
{ "action": "accept", "src": ["tag:bookmark-api"], "dst": ["tag:bookmark-browser:9222"] },
]
```
Without a rule the tailnet default is allow-all, so this step is what makes
"Tailscale identity is the access control" true rather than aspirational.
Prove the bind is tight, from the home machine itself: Prove the bind is tight, from the home machine itself:
```bash ```bash
@@ -337,16 +363,11 @@ a cover is stored it is served from Postgres forever after, so the browser being
asleep, unreachable, or mid-power-outage costs chapter freshness and nothing asleep, unreachable, or mid-power-outage costs chapter freshness and nothing
visible. visible.
**Updating the browser** is independent of the API stack: Finally, take the VPS `free -m` reading again and compare it against the one
from the top of this section.
```bash **Updating the browser** is independent of the API stack and has its own
cd ~/mangaBookmark/chrome && git pull && docker compose up -d --build runbook — `REDEPLOY.md` §8.
```
Rebuild is the Chrome upgrade path — the image installs `google-chrome-stable`
unpinned on purpose, because a stale browser is exactly what Cloudflare turns
away. The `chrome-profile` volume survives the rebuild, so the clearance cookies
are reused instead of re-solved.
--- ---
+1 -1
View File
@@ -457,7 +457,7 @@ and skipped, stored covers still served.
| kagane rows stopped updating after a redeploy | Check `BROWSER_WS_URL` survived the `.env` edit and still names the home machine's tailnet **IP**. A hostname 500s at `/json/version`; an empty value disables the browser silently. Plain-TLS sites keep working either way, which is why this is easy to miss. | | kagane rows stopped updating after a redeploy | Check `BROWSER_WS_URL` survived the `.env` edit and still names the home machine's tailnet **IP**. A hostname 500s at `/json/version`; an empty value disables the browser silently. Plain-TLS sites keep working either way, which is why this is easy to miss. |
| kagane covers went blank in the web UI | They should not — covers are rows in `covers`, not an in-process cache. `$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from covers'`. Zero after a restore means the dump predates the covers table; they refill on the next poll of each series. | | kagane covers went blank in the web UI | They should not — covers are rows in `covers`, not an in-process cache. `$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from covers'`. Zero after a restore means the dump predates the covers table; they refill on the next poll of each series. |
| Browser unit will not start: `set BROWSER_BIND_ADDR to this machine's tailnet IP` | `chrome/.env` is missing or the variable is empty. It has no default on purpose — an unset value must fail the deploy rather than publish an unauthenticated CDP port to the LAN. | | Browser unit will not start: `set BROWSER_BIND_ADDR to this machine's tailnet IP` | `chrome/.env` is missing or the variable is empty. It has no default on purpose — an unset value must fail the deploy rather than publish an unauthenticated CDP port to the LAN. |
| `bookmark-browser` shows `OOMKilled true` | The 512 MiB cap did its job. Read `docker logs bookmark-browser` before raising it: the cap is sized against a measured 645 MiB untuned peak and exists so the kernel never takes the Gitea runner instead. | | `bookmark-browser` shows `OOMKilled true` | The cap did its job. Read `docker logs bookmark-browser` before raising it — the sizing and what the cap protects are in ADR-0006. |
Full first-time setup: `DEPLOY.md`. The one-off SQLite→Postgres move: Full first-time setup: `DEPLOY.md`. The one-off SQLite→Postgres move:
`CUTOVER.md`. Config reference and endpoints: `README.md`. `CUTOVER.md`. Config reference and endpoints: `README.md`.
+7 -7
View File
@@ -13,14 +13,14 @@
# #
# For a throwaway local test, 127.0.0.1 is fine — but then only this machine # For a throwaway local test, 127.0.0.1 is fine — but then only this machine
# can reach it, so the API must run here too. # can reach it, so the API must run here too.
BROWSER_BIND_ADDR=100.x.y.z # Left commented so `cp .env.example .env && docker compose up` fails with the
# variable's own message telling you what to set, rather than Docker rejecting
# "100.x.y.z" as an invalid IP.
# BROWSER_BIND_ADDR=100.x.y.z
# Clock zone the browser reports. A UTC clock is itself the bot signal — # Clock zone the browser reports. Any real zone works and it need not match
# Cloudflare treats it as the datacenter default — and kagane's challenge then # the egress IP's country — but it must not be UTC, which is itself the bot
# never clears. Measured 2026-08-08, identical container, one Indonesian egress # signal that stops the challenge clearing. The measurement is in entrypoint.sh.
# IP: UTC never cleared in 60s (twice); Asia/Jakarta and America/New_York both
# cleared in 4s. So any real zone works; it does not have to match the IP's
# country, it just must not be UTC.
# #
# Unset falls back to the host's /etc/timezone, which is a real zone whenever # Unset falls back to the host's /etc/timezone, which is a real zone whenever
# the host clock is set to local time. Set this when the host runs UTC — a UTC # the host clock is set to local time. Set this when the host runs UTC — a UTC
+4 -8
View File
@@ -17,14 +17,10 @@ services:
container_name: bookmark-browser container_name: bookmark-browser
restart: unless-stopped restart: unless-stopped
environment: environment:
# A UTC clock is itself the bot signal: Cloudflare treats it as the # Any real zone works, but a UTC clock is itself the bot signal and the
# datacenter default, and kagane's challenge then never clears. Measured # challenge then never clears — measurement in entrypoint.sh. Unset falls
# 2026-08-08, identical container, one Indonesian egress IP: UTC never # back to the host's /etc/timezone below, which is a real zone whenever
# cleared in 60s (twice); Asia/Jakarta and America/New_York both cleared # the host clock is local; set BROWSER_TZ when the host runs UTC.
# in 4s. So any real zone works and it need not match the IP's country —
# only UTC fails. Unset falls back to the host's /etc/timezone below,
# which is a real zone whenever the host clock is set to local time; set
# BROWSER_TZ when the host runs UTC.
TZ: ${BROWSER_TZ:-} TZ: ${BROWSER_TZ:-}
volumes: volumes:
# The zone *name*, which is what Chrome's ICU needs — see entrypoint.sh. # The zone *name*, which is what Chrome's ICU needs — see entrypoint.sh.
+4 -6
View File
@@ -17,14 +17,12 @@ services:
bookmark-api: bookmark-api:
# Traffic arrives over the Traefik network, not a published port. # Traffic arrives over the Traefik network, not a published port.
ports: !reset [] ports: !reset []
# `networks:` here replaces the base file's list entirely, so both must be # Compose *merges* this list with the base file's, so the service ends up on
# named: `proxy` for Traefik routing, and `db` (defined in the base file) # `default`, `db` and `proxy` — only the addition is named here. Do not
# to keep reaching Postgres without putting it on `proxy`. `proxy` also # "tidy" the base file down to `db` on the strength of `proxy` being present:
# carries the poller's outbound traffic — `db` is `internal: true`, so a # `db` is `internal: true`, and egress comes from `default`.
# container on it alone has no egress at all.
networks: networks:
- proxy - proxy
- db
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=${PROXY_NETWORK:-proxy}" - "traefik.docker.network=${PROXY_NETWORK:-proxy}"
+1 -2
View File
@@ -86,8 +86,7 @@ services:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
# `default` is not decoration: `db` is `internal: true`, and a container on # `default` is not decoration: `db` is `internal: true`, and a container on
# nothing but an internal network gets neither a published port nor egress # nothing but an internal network gets neither a published port nor egress
# — which would silently kill every poller fetch. The removed `browser` # — which would silently kill every poller fetch.
# network used to be what supplied both.
networks: networks:
- default - default
- db - db