Files
mangaBookmark/DEPLOY.md
T
sulthan 77965c3d76 docs: close the environment contract and de-ambiguate volume derivation (#26)
Review findings on the previous commit:

- README's config table listed 8 of the backend's 25 environment variables,
  omitting the whole DISCORD_* set that the backend refuses to start without.
  The canonical reference cannot be missing the vars that gate startup.
- `docker volume ls --filter name=` is a substring match. Both runbooks used
  it to find a volume they then mount read-only or delete; a second matching
  volume makes the mount fail obscurely and the delete take both. Derive the
  name exactly from the compose project instead.
- CUTOVER §6 removes the retired volume 'once the Postgres data has been
  trusted for a while', in a shell where §1's $VOL no longer exists.
- REDEPLOY and DEPLOY still pointed at /opt/bookmarkmanager, so CUTOVER §6's
  handoff to REDEPLOY §1 sent the operator to a path that does not exist.
2026-08-08 15:50:59 +07:00

14 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. ~/mangaBookmark/ (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 ~/mangaBookmark
cp .env.example .env

Edit .env:

# Required — secret every Reader's userscript credential is derived from.
# Only SHA-256 hashes of credentials are stored.
TOKEN_KEY=<paste output of: openssl rand -hex 32>

# Required — the owner's Discord user ID. Seeds the one Reader every bookmark
# belongs to; the value is the snowflake in your Discord profile (Settings →
# Advanced → Developer Mode → right-click your name → Copy User ID).
OWNER_DISCORD_ID=<discord user id>

# 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 the web UI
# were unused; 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|^TOKEN_KEY=.*|TOKEN_KEY=$(openssl rand -hex 32)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" .env
grep -E '^TOKEN_KEY=' .env

TOKEN_KEY derives every Reader's userscript credential (issue #24); only SHA-256 hashes of the credentials are stored, so this secret is what a database leak alone cannot recover. If you are upgrading across the cutover, also set API_TOKEN (the retired global credential) and API_TOKEN_GRACE_UNTIL in .env so already-installed scripts keep working for the window — see §6.

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. Sign-in is a Discord authorization code grant (ADR-0002): the owner's Discord account, gated by membership in one configured guild.

  1. Add a DNS A/AAAA record for bookmark.<yourdomain> pointing at the server — the same address as bookmark-api.<yourdomain>.

  2. Create the Discord application at https://discord.com/developers/applications:

    • OAuth2 → Redirects: add the exact callback URL https://bookmark.violetcrown.my.id/auth/discord/callback. Discord matches it verbatim — a trailing slash or different hostname breaks sign-in.
    • OAuth2 → General: note the Client ID, and generate a Client Secret.
    • No scopes or bot setup are needed in the dashboard; the service requests identify and guilds.members.read itself, and checks the user's membership of the guild, not the application's.
  3. Set the variables in .env:

    BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
    DISCORD_CLIENT_ID=<client id>
    DISCORD_CLIENT_SECRET=<client secret>
    DISCORD_GUILD_ID=<guild snowflake>
    DISCORD_REDIRECT_URI=https://bookmark.violetcrown.my.id/auth/discord/callback
    # Optional: only members holding this role may sign in.
    # DISCORD_REQUIRED_ROLE=<role snowflake>
    

    The guild id is in Discord's client with Developer Mode on: right-click the server name → Copy Server ID. The four uncommented variables are required — the backend refuses to start without them. OWNER_DISCORD_ID from §1 is the only Discord identity allowed to sign in while registration is closed.

  4. 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 with the Discord button. Signing in lands on the library; an account outside the guild is refused with a message that names neither the guild nor its id.

Sessions are rows in the database: the cookie carries only an opaque id, and every request looks the row up and checks its expiry. Deleting a session row — or the whole sessions table — logs the browser out immediately; nothing is signed, so rotating a credential does not affect browser sessions. Sessions last 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

# During the grace window the retired global credential still resolves to the
# owner; afterwards it is 401 like any other wrong credential.
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

The bindmounted userscript/*.user.js files carry __API_TOKEN__ placeholders and the deployment's @downloadURL/@updateURL lines. Check the metadata block — it ships hardcoded to this deployment's domain, so a deployer who copies the repo to another domain must edit the two lines or the script auto-updates from someone else's backend:

// @downloadURL  https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js
// @updateURL    https://bookmark-api.yourdomain.com/u/__API_TOKEN__/manga-bookmark.user.js

The backend substitutes __API_TOKEN__ with the requesting Reader's derived credential at serve time (issue #24), so no real credential ever sits in the file. Only the API_BASE constant and the metadata hostname are deployer edits; do not put a credential in this file.


5. Install on Bromite

  1. Bromite → Settings → User scripts → enable (accept the permission prompt).
  2. Sign in to the web UI, open the Userscripts panel, and open the install link — Bromite detects .user.js and offers to install. The script already carries your credential; you never see or type one.
  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.

Rotating the credential in the same web-UI panel invalidates every installed copy immediately — reinstall on all devices, or they silently stop syncing.


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 credential The script's credential no longer matches the stored hash — most likely a rotation happened and the device was not reinstalled. Reinstall from the web UI.
401 after rotation, even right after reinstalling TOKEN_KEY changed between the rotation and the reinstall; credentials are derived from it, so changing it invalidates every credential. Keep it stable.
Panel button absent URL didn't match an adapter, or user scripts disabled in Bromite.
compose ... config errors about TOKEN_KEY, OWNER_DISCORD_ID or POSTGRES_PASSWORD Run compose from the dir with .env, or export the vars. All three are required and none 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 — the @downloadURL/@updateURL lines point at the credential-bearing path, so auto-updates come from the same place as the install.

Install once, on the phone (Cromite + Violentmonkey): sign in to the web UI, open the Userscripts panel, and open the install link for the library — the script is served with your credential already inside it. Its @downloadURL/@updateURL point at the same credential-bearing path for updates:

https://bookmark-api.<your-domain>/u/<your credential>/manga-bookmark.user.js

Violentmonkey offers to install it. The credential is in the path because Violentmonkey's update poll sends no Authorization header, and the script embeds the credential in plain text — an open URL would leak it. A wrong credential answers 404. The credential is derived from TOKEN_KEY and never appears anywhere but this URL and the rendered script.

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.