Reviewed-on: #69 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Runic Gateway Website
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
full-stack app in one repo. Branding is instance-configurable via BRAND_* (see
Branding); UOMysticmoon is the first instance.
A full-stack app in one repo:
- Backend — Node.js + Express REST API (layered
router → controller → model → db), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - Frontend — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- Deploy — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
- Shard link — a live bridge to the in-game ServUO shard through the uo-link sidecar (RunicGateway/link): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See Shard integration (uo-link).
The design reference is BACKEND_DESIGN.md (API contract, schema, security), in the RunicGateway/docs repo — where all project documentation now lives.
Contents
- Tech stack
- Project structure
- Prerequisites
- Setup & run
- First admin & site mode
- Pages & routes
- API endpoints
- API documentation (Swagger)
- Shard integration (uo-link)
- Environment variables
- Security
- Logging
- Deployment behind Pangolin
Tech stack
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, mariadb driver (parameterized SQL, no ORM) |
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (speakeasy + qrcode), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
Nodemailer via Gmail OAuth2 (configured in admin), with a mailto: fallback |
|
| API docs | OpenAPI 3.0 via swagger-autogen, served with swagger-ui-express at /api/docs |
| Deploy | Docker Compose, Pangolin reverse proxy |
Project structure
website/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
│ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image
├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose)
└─ package.json workspace scripts
Prerequisites
- Node.js 20+ and npm (Node 22/24 are fine).
- Docker Desktop (for MariaDB, and for the full Compose deploy).
Setup & run
Option A — Docker Compose (full stack)
docker-compose.yml is production-shaped: it pulls the prebuilt app and bot images from
the Gitea container registry (published by .gitea/workflows/build-images.yml on every merge to
main) — it never builds. Each image already bundles the server deps and the built React client,
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
created automatically on first boot.
cp .env.example .env
# Edit .env and set at least:
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
# JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
- App: http://localhost:3000 (binds
0.0.0.0) - Health check:
GET http://localhost:3000/api/health→{ "status": "ok" } - Logs:
docker compose logs -f app(and./logs/app.logon the host) - Stop:
docker compose down(add-vto also wipe the database + uploads volumes)
Build the images locally instead of pulling (offline, or to test an unmerged change) — overlay
the dev file, which adds build: back:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
Keeping build: out of the base file means a production host can only ever pull — it can never
accidentally build.
Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies /api and /uploads
to the backend, so the SPA stays same-origin (cookies work).
1. Start a MariaDB the backend can reach (published on localhost:3306):
docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
2. Configure + start the backend (terminal 1):
cp server/.env.example server/.env
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
npm run install-server
npm run server # nodemon → http://localhost:3000
3. Start the frontend (terminal 2):
npm run install-client
npm run client # Vite → http://localhost:5173
Develop at http://localhost:5173 (hot reload). On Windows, the Vite proxy targets
127.0.0.1:3000 to avoid the IPv6-localhost pitfall.
Tip:
npm run install-allinstalls both server and client deps in one go.
Option C — Production build without Docker
Build the SPA and let Express serve it on a single port (still needs a MariaDB + server/.env):
npm run install-all
npm run build # → client/dist
npm start # node server → serves API + SPA at http://localhost:3000
First admin & site mode
- On first boot, if the
userstable is empty andADMIN_USERNAME/ADMIN_PASSWORDare set, the first admin is created automatically. You can also runnpm run seed. After it exists you may blank those env vars. - The site starts in
maintenancemode: public visitors see the polished "coming soon" page; the admin login and panel are always reachable. - Sign in at
/admin/login, then flip Maintenance → Live from the Dashboard. A logged-in admin can preview the live site even while it's in maintenance.
Pages & routes
Public (gated by site mode):
| Route | Page |
|---|---|
/ |
Portal landing (hero + destinations) |
/site |
Website index (section cards) |
/site/news |
News feed |
/site/screenshots |
Screenshot gallery |
/site/five-on-friday |
Five on Friday |
/site/newsletter · /site/newsletter/:id |
Newsletter list + issue |
/site/about · /site/status |
About · Shard status |
/wiki · /wiki/:slug |
Wiki landing + article (auto table-of-contents) |
Admin (cookie auth, noindex):
| Route | View |
|---|---|
/admin/login |
Sign in |
/admin |
Dashboard (mode toggle, stats, recent activity) |
/admin/posts |
Posts CRUD + publish + image upload |
/admin/wiki |
Wiki pages CRUD |
/admin/settings |
Site settings |
/admin/activity |
Activity log |
/admin/bot-activity |
Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
/admin/auth-providers |
Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
/admin/users |
User management |
/admin/account |
Account security (self-service TOTP two-factor + linked SSO accounts) |
API endpoints
| Group | Base | Auth |
|---|---|---|
| Auth (web) | /api/v1/auth (login, login/totp, logout, me) |
cookie |
| Auth (mobile) | /api/v1/auth/mobile (login, refresh, logout) |
bearer (access + refresh tokens) |
| SSO | /api/v1/auth (providers — public discovery; sso/:provider/start, sso/:provider/link, sso/:provider/callback) |
redirect flow |
| Public | /api/v1/public (settings, status, posts/:category, posts/:category/:idOrSlug, wiki, wiki/:slug, contact) |
none |
| Admin | /api/v1/admin (dashboard, site-mode, posts, posts/upload, wiki, settings, activity, bot-activity, bot-activity/unban, auth/providers (CRUD), users, account, account/totp/*, account/identities) |
cookie (admin) |
| Public · Shard | /api/v1/public/shard (status, feed, economy, online, idoc, stream) |
none |
| Player · Shard | /api/v1/player/shard (link, accounts, roster/:account, vendors/:account, char/:serial, sales) |
cookie/bearer (player) |
| Admin · Shard | /api/v1/admin/shard (self linking, same as player) · /api/v1/admin/uo-link (config, towncrier, stream) |
cookie (staff / admin) |
Post categories (URL form): news, five-on-friday, newsletter, screenshots.
authMethod on a session ∈ local · totp · mobile · google · discord · oidc.
See BACKEND_DESIGN.md §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes).
API documentation (Swagger)
The full API is documented as an OpenAPI 3.0 spec and served with Swagger UI:
| URL | What |
|---|---|
http://localhost:3000/api/docs |
Interactive Swagger UI (try-it-out, auth) |
http://localhost:3000/api/docs.json |
Raw OpenAPI 3.0 spec (JSON) |
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
groups) with its summary, parameters, request body, security requirement, and the response codes it
actually returns (400 validation, 401/403 auth, 404, 409 conflicts, 429 rate limits, …).
Authentication in the UI — click Authorize and provide either:
cookieAuth— the session cookie (namerg_token, configurable viaCOOKIE_NAME; set automatically in the browser afterPOST /api/v1/auth/login), orbearerAuth— a mobile access token fromPOST /api/v1/auth/mobile/login(sent asAuthorization: Bearer <token>).
Regenerating the spec — the spec is generated from #swagger.* annotations next to each route
(server/src/router/**) plus the shared definitions in server/swagger/swagger.js
(swagger-autogen). The output
server/swagger/swagger-output.json is committed so the docs work with no build step. After adding
or changing a route, regenerate it:
cd server
npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables /api/docs (it does
not crash).
Shard integration (uo-link)
The site is wired to the live in-game world through uo-link, a standalone sidecar service that runs next to the ServUO shard. Its source lives in a separate repo: RunicGateway/link. uo-link speaks the shard's internals and exposes a small, authenticated HTTP + WebSocket API; this website is a client of it. The shard itself is never exposed to the internet — only the sidecar is, and only the website's backend talks to it.
How it works
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
- Connection is admin-managed, not env. The sidecar's base URL, WebSocket URL, shared-secret
token, and protocol version are stored in the database (
uoLinkConfig), edited from the Admin → Shard panel. The token is encrypted at rest (AES-256-GCM) and is write-only in the API — it is never returned to any client and never sent to the browser. Every call the backend makes carriesAuthorization: Bearer <token>and anX-UOLink-Versionheader (a protocol mismatch fails fast with409instead of being mis-parsed). - Live ingest (WebSocket). When enabled, the backend opens an outbound WebSocket to the sidecar
and receives a stream of game events —
mob.login/logout,char.vitals,economy.supply,vendor.sale,player.death/murdered,house.decay(IDOC), staffaudit.*/cheat.*,link.request, andserver.hello/shutdown. A single dispatcher (utils/shardIngest.js) routes each event: state-changing kinds updateshard_online/shard_economy/shard_houses; notable kinds are appended to an append-onlyshard_eventslog; high-frequency kinds (vitals, supply ticks) only update state and are not logged. A changed boot id onserver.hellois detected as a restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via the sidecar's/history. - Live round-trips (REST). For point-in-time reads the backend calls the sidecar directly —
/char/serial/:serial,/roster/:account,/vendors/:account,/economy,/history— plus commands/link/confirmand/towncrier. The REST client (utils/uoLinkClient.js) never throws: every call returns{ ok, data, status }, so a shard that is down or mid-restart degrades to a503/retry banner instead of a 500. - Fan-out to the browser. Ingested events are pushed to browsers over Server-Sent Events. Two channels exist: a public stream carrying only a safe allowlist of kinds, and an admin-only stream that also includes sensitive kinds (staff audit, cheat detection, login attempts, IPs). Sensitive kinds can never leak onto the public channel.
Account linking
A player (or staff member) proves ownership of a game account without sharing any game credentials:
- In game, the player runs
[linkand receives a one-time code. - On the website (Player portal, or Admin → Account for staff) they enter the code.
- The backend confirms the code with the sidecar (
POST /link/confirm), which permanently tags the game account with the website user id, and mirrors the link locally inshard_account_links.
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints are ownership-checked so a user only sees accounts they linked. Admins may view any character; players and editor/moderator staff are limited to their own linked accounts.
What each audience sees
| Surface | Endpoints | Who | Data |
|---|---|---|---|
| Public | /api/v1/public/shard/* (status, feed, economy, online, idoc, stream) |
anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and "Staff online" — only players whose account is linked to a staff user (admin/editor/moderator), shown with name + map location. Linked players are never listed publicly; no vitals or account are exposed. |
| Player | /api/v1/player/shard/* (link, accounts, roster/:account, vendors/:account, char/:serial, sales) |
logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
| Admin | /api/v1/admin/shard/* (self-linking, same as player) · /api/v1/admin/uo-link/* (config, towncrier, stream) |
staff / admin | Staff link their own accounts like players; admins additionally read any character's data, edit the sidecar connection config, publish/remove town-crier messages, and subscribe to the full event stream (incl. audit/cheat). |
The sidecar URL and token are set once in Admin → Shard; if uo-link is not configured (or the shard is offline), every shard surface degrades gracefully — the public page still renders, showing the shard as offline.
Environment variables
Copy .env.example (Compose) or server/.env.example (local) and fill in. .env is git-ignored.
| Var | Default | Notes |
|---|---|---|
NODE_ENV |
production |
|
PORT |
3000 |
server listens on 0.0.0.0:PORT |
UPLOAD_DIR |
<server>/uploads |
where post images are written (/app/uploads, volume-mounted, in Compose) |
DB_HOST / DB_PORT |
db / 3306 |
db in Compose; 127.0.0.1 for local dev |
DB_NAME / DB_USER / DB_PASSWORD |
runic_gateway / runic / — |
app database credentials |
DB_ROOT_PASSWORD |
— | MariaDB root (Compose only) |
JWT_SECRET |
— | required — long random string; signs session, mobile, and SSO-flow tokens |
JWT_EXPIRES_IN |
1d |
web session token + cookie lifetime |
COOKIE_SECURE |
auto |
auto = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
COOKIE_NAME |
rg_token |
changing it on a live instance invalidates existing sessions |
BRAND_* |
Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see Branding |
SECRET_ENC_KEY |
— | required in prod — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from JWT_SECRET (with a warning) |
APP_BASE_URL |
— | public base URL, used to build the SSO OAuth redirect_uri (${APP_BASE_URL}/api/v1/auth/sso/:provider/callback). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
MOBILE_ACCESS_TTL |
15m |
mobile bearer access token lifetime (short-lived) |
MOBILE_REFRESH_TTL_DAYS |
30 |
mobile refresh token lifetime (long-lived, rotated on use) |
TRUST_PROXY |
1 |
reverse-proxy trust for correct req.ip / req.secure (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket true is rejected (coerced to 1) to block X-Forwarded-For spoofing |
DEBUG_TRUST_PROXY |
0 |
1 logs raw peer address + X-Forwarded-For + resolved req.ip per request (to verify/refresh the proxy IP). Noisy — leave off |
TOTP_ISSUER |
BRAND_NAME |
label shown in authenticator apps for optional per-user 2FA |
TOTP_CHALLENGE_TTL |
5m |
lifetime of the short-lived post-password "awaiting code" step |
ADMIN_USERNAME / ADMIN_PASSWORD |
— | first-admin bootstrap (first boot only) |
| — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = contact_email setting |
|
CLIENT_ORIGIN |
http://localhost:5173 |
enables CORS in dev only |
LOG_LEVEL / FILE_LOG_LEVEL |
info / debug |
console / file verbosity |
LOG_TO_FILE / LOG_DIR / LOG_FILE |
true / <server>/logs / app.log |
log file (bind-mounted to ./logs in Docker) |
ANNOUNCE_POLL_MS |
15000 |
how often the news-announcement dispatcher sweeps announce_jobs for due/retry legs (town crier + Discord) |
TOWNCRIER_DURATION_SEC |
3600 |
how long a news post's in-game town-crier message stays up (≤ 86400) |
Branding
Instance identity is data, not code — set via BRAND_* env vars, so one prebuilt
image can run as any shard. With none set, everything renders as Runic Gateway.
| Var | What |
|---|---|
BRAND_NAME / BRAND_SHORT_NAME |
display name (full / short-in-prose) |
BRAND_TAGLINE / BRAND_DESCRIPTION |
tagline + meta/OG description |
BRAND_CONTACT_EMAIL / BRAND_URL |
contact + canonical URL (for OG/absolute links) |
BRAND_ACCENT_COLOR |
theme --accent (web) + Discord embed color |
BRAND_LOGO / BRAND_HERO / BRAND_FAVICON |
image paths under the /brand mount, or absolute URLs |
How it flows: text/colors reach the SPA at runtime through the public settings
API (SiteContext), so no rebuild is needed; the server templates index.html
<title>/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
BRAND_* directly. The admin-editable site title and contact email
settings override BRAND_NAME / BRAND_CONTACT_EMAIL when set. Image assets are
delivered from the ./brand bind-mount (see brand/README.md).
UOMysticmoon is the first instance — .env.uomysticmoon.example
holds the exact BRAND_* + infra (DB_NAME/DB_USER/COOKIE_NAME) pinning to
run this repo as UOMysticmoon.
Security
Session & authorization
- All auth flows go through one session service (
server/src/auth/): controllers callsessionService.createSession(user, authMethod)and middleware callsvalidateSession(), so web cookies, mobile bearer tokens, and SSO all produce the same authenticated session model.utils/auth.jsremains a thin backward-compat facade. - JWT in an httpOnly,
SameSite=Laxcookie (Secureauto-detected), bcrypt password hashing. - Admin routes are re-validated against the database on every request, so a demoted or deleted user loses access immediately instead of keeping their old role until the token expires.
- Role-based authorization — admin-only endpoints (users, site mode, settings, auth providers)
are gated by a
requireRolecheck, so a lower-privilege editor can't reach them.
Mobile bearer auth
- Native clients use
/api/v1/auth/mobile/*: a short-lived access token (bearer JWT, validated by the same middleware as the cookie) plus a long-lived, server-stored, revocable refresh token that is rotated on every refresh (a replayed refresh token is single-use). Refresh tokens are stored hashed (never in the clear); logout revokes one or all. Mobile login reuses the same bot-scoring + backoff defenses as web, with single-request TOTP.
Single sign-on (OAuth2 / OIDC)
- Pluggable providers — built-in Google and Discord (endpoints fixed in code; admins supply
only client id/secret) plus fully-configurable custom OIDC/OAuth2 providers, managed from the
Authentication admin panel. Only
enabled+ fully-configured providers are shown to users. - Link-only by policy: an SSO login succeeds only if the external identity is already linked to an existing account (linked by the user from Account). External identities are never auto-provisioned — no one gains access without an account you created.
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
PKCE; OAuth client secrets are encrypted at rest (AES-256-GCM) and never returned to any
client. SSO logins go through the same
sessionService, so login/activity logging, RBAC, and bot protection are identical to a local login.
Login hardening
- Optional per-user TOTP two-factor (opt-in, self-service on
/admin/account). When enabled, the password step issues only a short-lived, non-sessionstage:'totp'challenge; a session cookie is granted only after the second factor verifies. - Login throttling —
express-slow-down+ a hard rate cap + a separate per-IP exponential backoff, with generic error messages that don't reveal whether the username exists. - Honeypot field on the login form; submissions that fill it are treated as bots.
- Bot-scoring + automatic IP ban — weighted scoring of CMS-scanner paths and junk 404s (with a periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the score. Admins get visibility into this on the Bot Activity panel: currently banned IPs and a recent-events feed (in-memory, most-recent-first), plus a logged emergency unban for false positives — read + unban only, not a scoring-config surface.
Uploads & input
- Uploaded file extensions are derived from the validated mimetype, not the client-supplied filename (prevents a disguised-extension upload).
express-validatoron all writes; usernames are validated and uniqueness-checked on update.
Platform
helmet, admin routesnoindex+robots.txtdisallow,trust proxyfor correct client IPs behind Pangolin (seeTRUST_PROXY), first admin seeded from env (no hardcoded credentials),.envgit-ignored. Passwords and request bodies are never logged. Email sends through Gmail OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the contact form falls back to amailto:link when unconfigured.
Logging
Every log line goes to both the console and a log file, timestamped and leveled
(error / warn / info / debug):
2026-06-26T18:55:01.123Z INFO [server] listening on http://0.0.0.0:3000 ...
2026-06-26T18:55:09.880Z INFO [http] 192.168.1.40 admin POST /api/v1/auth/login 200 12 ms - 48 bytes
2026-06-26T18:55:14.402Z WARN [auth] login failed {"username":"root","ip":"192.168.1.40"}
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
Captured: startup config banner, schema/seed steps, HTTP access logs (real client IP via
trust proxy, the authenticated admin, method/URL/status/time/size), login success/failure,
rate-limit hits, site-mode changes, all errors with stack traces, and graceful shutdown. Console
verbosity is LOG_LEVEL; the file keeps the fuller FILE_LOG_LEVEL record. In Docker the file is
bind-mounted to ./logs/app.log and docker compose logs -f app shows the console stream.
Deployment behind Pangolin
docker compose up -d --build exposes the app container on 0.0.0.0:3000 (no 127.0.0.1
binding) so Pangolin can reach it. Point a Pangolin resource at app:3000. Because COOKIE_SECURE
defaults to auto, the admin login works both directly via the LAN IP over HTTP and through
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
(no published port by default); data persists in the dbdata volume, uploads in uploads.