Files
mangaBookmark/DEPLOY.md
T
sulthan 08749df050 feat(backend)!: run on Postgres with a migration-owned schema (#28)
Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change:
same endpoints, same wire format, same updated_at ordering rule.

The schema now comes from numbered SQL embedded in the binary and
applied on startup, one transaction each, recorded in
schema_migrations. That replaces two pieces of SQLite-era machinery,
both deleted rather than ported: the column probing (Postgres has ADD
COLUMN IF NOT EXISTS, and there is no legacy database left to probe)
and the Asura key rewrite, which has run clean on every start for
months now that the userscripts strip build hashes before writing. Its
regexp survives as latest.asuraBuildHash, where the poller still needs
it to scope chapter links to a series whose slug carries a rotating
hash.

Types get real: favorite is a boolean, chapter numbers double
precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT
becomes IS DISTINCT FROM, which is what implements the rule that only
reading progress reorders a list. Inside COALESCE/NULLIF the status
and kind parameters need an explicit ::text -- there is no target
column to infer from and Postgres refuses to guess.

Tests lose their free t.TempDir() database, so Docker is now a hard
prerequisite for `go test ./...`: internal/pgtest starts one
postgres:17-alpine per test binary and hands each test a database of
its own.

Also lands CONTEXT.md and the four ADRs written while scoping #18.

BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is
required and has no default. Compose gains a postgres service on an
internal network with its own volume; POSTGRES_PASSWORD joins .env.
The old bookmarks-data volume is deliberately left undeclared so
`docker compose down -v` cannot take the pre-migration database with
it. main is not deployable until #25 and #26 land.

Closes #20

Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-08 06:52:20 +07:00

298 lines
11 KiB
Markdown

# Deployment
Step-by-step for the backend (Docker + Traefik) and the Bromite userscript.
Assumes you already run Traefik in Docker with a working HTTPS entrypoint and an
ACME/cert resolver, and control a domain.
---
## 0. Prerequisites
- Docker + Docker Compose on the server.
- A Traefik instance watching a Docker network (default name assumed: `proxy`).
- DNS: an `A`/`AAAA` record for `bookmark-api.<yourdomain>` pointing at the server.
- The repo copied to the server, e.g. `/opt/bookmarkmanager/` (needs `backend/`,
`docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`).
Confirm the Traefik network exists (create if not):
```bash
docker network ls | grep proxy || docker network create proxy
```
---
## 1. Configure `.env`
```bash
cd /opt/bookmarkmanager
cp .env.example .env
```
Edit `.env`:
```ini
# Required — long random secret, also goes in the userscript.
API_TOKEN=<paste output of: openssl rand -hex 32>
# CORS allowlist — leave as-is unless a site changes hostname.
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to
# Required — password for the bundled Postgres container. Compose builds the
# backend's DATABASE_URL out of it and has no fallback for either.
POSTGRES_PASSWORD=<paste output of: openssl rand -hex 24>
# Leave unset. Only set this to point the backend at a Postgres compose does
# not run; it then replaces the URL built from POSTGRES_PASSWORD above.
# DATABASE_URL=postgres://user:pass@host:5432/bookmarks?sslmode=require
# Required for the Traefik override. Both have no fallback — compose refuses
# to start without them. BOOKMARK_WEB_HOST is required even if you never set
# WEB_PASSWORD; see 1b.
BOOKMARK_API_HOST=bookmark-api.violetcrown.my.id
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
# Only if your Traefik setup differs from these defaults:
# PROXY_NETWORK=proxy
# TRAEFIK_ENTRYPOINT=websecure
# TRAEFIK_CERTRESOLVER=le
```
Generate + insert the two secrets in three lines:
```bash
sed -i "s|^API_TOKEN=.*|API_TOKEN=$(openssl rand -hex 32)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env
grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value
```
`POSTGRES_PASSWORD` is read **only while the `postgres-data` volume is empty**,
which in practice means at first boot. Changing it afterwards changes the URL
the backend dials but not the password the database expects, and `bookmark-api`
crash-loops on `password authentication failed`. Set it before §2 and leave it
alone.
> Match `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER` to your Traefik's actual
> names (check your Traefik static config — common alternatives: `https`,
> `myresolver`, `cloudflare`). Wrong names = no certificate issued.
---
## 1b. Web UI
The browser UI is served by the same container on a second hostname.
1. Add a DNS `A`/`AAAA` record for `bookmark.<yourdomain>` pointing at the server —
the same address as `bookmark-api.<yourdomain>`.
2. Set both variables in `.env`:
```ini
BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
WEB_PASSWORD=<paste output of: openssl rand -base64 18>
```
Generate and insert in one line:
```bash
sed -i "s|^WEB_PASSWORD=.*|WEB_PASSWORD=$(openssl rand -base64 18)|" .env
grep -E '^WEB_PASSWORD=' .env # this is what you type into the site
```
3. Redeploy and check:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
curl -s -o /dev/null -w '%{http_code}\n' https://bookmark.violetcrown.my.id/
```
Expected `200`, serving the login page.
Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/`
returns 404. The userscript's API on `BOOKMARK_API_HOST` is unaffected either way.
`BOOKMARK_WEB_HOST` itself is required by the prod override regardless — like
`BOOKMARK_API_HOST`, its Traefik label has no fallback, so `docker compose up`
refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is
otherwise dormant.
Sessions are signed with a key derived from `API_TOKEN` and `WEB_PASSWORD`, so
rotating either one logs every browser out. The session cookie lasts 60 days.
---
## 2. Build + start
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
This merges the base file (build/image/env/volume) with the prod override
(no host port, Traefik network + router labels). Always pass **both** `-f`
flags — the prod file is not standalone.
Three services come up: `bookmark-api` (the backend), `postgres` (its database,
`postgres:17-alpine`), and `headless-shell`, a CDP sidecar the poller uses to
fetch kagane (behind a Cloudflare JS challenge). Neither of the latter two
publishes a port: `postgres` sits alone with `bookmark-api` on an
`internal: true` network, and `headless-shell` is reachable only over
`BROWSER_WS_URL`. A missing headless-shell just makes the poller skip kagane and
log it. A missing Postgres stops everything — `bookmark-api` waits for
`pg_isready` to pass, then applies its embedded migrations, and only then
listens. The schema is created that way; there is nothing to import by hand.
Check it's up and healthy:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
# bookmark-api Up; postgres Up (healthy)
docker logs bookmark-api --tail 20 # expect: "listening on :8080 ..."
```
---
## 3. Verify over HTTPS
Give Traefik a few seconds to issue the cert, then:
```bash
# Health (no auth) — must be valid TLS, no cert warning.
curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok
# Auth enforced.
curl -s -o /dev/null -w '%{http_code}\n' \
https://bookmark-api.violetcrown.my.id/bookmarks # -> 401
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s -H "Authorization: Bearer $TOKEN" \
https://bookmark-api.violetcrown.my.id/bookmarks # -> []
# CORS preflight from a real site origin.
curl -s -i -X OPTIONS \
-H 'Origin: https://asurascans.com' \
-H 'Access-Control-Request-Method: PUT' \
https://bookmark-api.violetcrown.my.id/bookmarks/x | grep -i access-control
# -> Access-Control-Allow-Origin: https://asurascans.com (+ Methods/Headers)
```
All four must pass. Valid TLS is non-negotiable — the manga sites are HTTPS, so
a bad cert makes the browser block the userscript's `fetch()` (mixed content).
---
## 4. Configure the userscript
Edit the config block at the top of `userscript/manga-bookmark.user.js`:
```js
const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash
const API_TOKEN = "<same token as .env>";
```
The token sits in the userscript's isolated world — the manga sites' JS can't
read it.
Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the
file — they ship hardcoded to this deployment's domain and token, so a
deployer who skips them ends up auto-updating from someone else's backend.
See "Installing / updating the userscript" below for how those two lines are
used.
---
## 5. Install on Bromite
1. Bromite → **Settings → User scripts** → enable (accept the permission prompt).
2. Put the edited `manga-bookmark.user.js` on the device (save the file, or open
its raw URL). Bromite detects `.user.js` and offers to install.
3. Confirm install — the `@match` list covers both sites.
4. Open a series on asurascans.com or demonicscans.org → a 📑 button appears
bottom-right → tap → **+ Bookmark this**.
Optional desktop test: the script is `GM_*`-free, so the same file installs in
Tampermonkey/Violentmonkey for quick checks before going mobile.
---
## 6. Smoke-test the full loop
1. Bookmark a series on Asura.
2. `curl -s -H "Authorization: Bearer $TOKEN" https://bookmark-api.yourdomain.com/bookmarks`
on the server — the series should appear.
3. Open a chapter of that series — reopen the panel; last-read updates to that
chapter (auto, never regresses on older chapters).
4. Open Demonic, open the panel — the Asura bookmark shows there too (shared
store, cross-site unified list).
---
## Updating
Pull new code, then rebuild:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Data persists in the named volume `postgres-data` across rebuilds. (If this
server predates the Postgres migration, the old SQLite volume `bookmarks-data`
is still on disk and deliberately undeclared in compose so `down -v` cannot take
it; see `REDEPLOY.md` §1 for when to remove it.)
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---------|--------------------|
| No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs <traefik>`. |
| 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. |
| `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). |
| 401 with the right token | Trailing space/newline in `API_TOKEN`; regenerate and restart. |
| Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. |
| `compose ... config` errors about `API_TOKEN` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. Both are required and neither has a fallback. |
| `bookmark-api` restarts in a loop, `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` was changed after first boot; Postgres only applies it to an empty `postgres-data`. Restore the old value, or reset the role (`REDEPLOY.md` troubleshooting). |
| `bookmark-api` never logs `listening on :8080` | It is blocked on `postgres` passing `pg_isready`, or a migration failed. `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs postgres`. |
Backend config reference and endpoint list: see `README.md`.
---
## Installing / updating the userscript
The backend serves the script itself, so Violentmonkey can auto-update it.
Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your
backend; this one points `@downloadURL`/`@updateURL` at the same place so
auto-updates come from it too.
Install once, on the phone (Cromite + Violentmonkey):
```
https://bookmark-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js
```
Open that URL in Cromite; Violentmonkey offers to install it. The token is in
the path because Violentmonkey's update poll sends no `Authorization` header,
and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A
wrong token answers 404.
Updating, without a redeploy:
```bash
vi userscript/manga-bookmark.user.js # on the VPS, in this checkout
```
`./userscript` is bindmounted read-only into the container and read fresh on
every request, so the edit is live immediately. The served `@version` is derived
from the file's mtime (`YYYY.MM.DD.HHMM`, UTC), not from the `@version` in the
file, so any edit outranks the installed copy and Violentmonkey pulls it on its
next check. The `@version` in the repo is a human marker only.
Updating via redeploy: `git pull` overwrites the file with the committed
version, which is the intended behaviour — a deploy always ships the repo's
script. Note that `git pull` sets mtime to checkout time, so even a rollback
serves a *higher* version and is adopted.
If the mount is missing, the endpoint answers 404 and logs it; bookmark sync is
unaffected.