Files
mangaBookmark/DEPLOY.md
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

11 KiB

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):

docker network ls | grep proxy || docker network create proxy

1. Configure .env

cd /opt/bookmarkmanager
cp .env.example .env

Edit .env:

# 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:

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:

    BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
    WEB_PASSWORD=<paste output of: openssl rand -base64 18>
    

    Generate and insert in one line:

    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:

    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

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:

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:

# 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:

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:

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:

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.