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.
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/AAAArecord forbookmark-api.<yourdomain>pointing at the server. - The repo copied to the server, e.g.
~/mangaBookmark/(needsbackend/,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_CERTRESOLVERto 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.
-
Add a DNS
A/AAAArecord forbookmark.<yourdomain>pointing at the server — the same address asbookmark-api.<yourdomain>. -
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
identifyandguilds.members.readitself, and checks the user's membership of the guild, not the application's.
- OAuth2 → Redirects: add the exact callback URL
-
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_IDfrom §1 is the only Discord identity allowed to sign in while registration is closed. -
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
- Bromite → Settings → User scripts → enable (accept the permission prompt).
- Sign in to the web UI, open the Userscripts panel, and open the install
link — Bromite detects
.user.jsand offers to install. The script already carries your credential; you never see or type one. - Confirm install — the
@matchlist covers both sites. - 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
- Bookmark a series on Asura.
curl -s -H "Authorization: Bearer $TOKEN" https://bookmark-api.yourdomain.com/bookmarkson the server — the series should appear.- Open a chapter of that series — reopen the panel; last-read updates to that chapter (auto, never regresses on older chapters).
- 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.