Public read-only demo instance
Context
Section titled “Context”We want a public demo so prospective operators can browse the whole admin + review surface without installing anything. The hard constraints: it must be read-only (no visitor can mutate shared state), expose no sensitive config or guessable admin, and track releases with near-zero manual upkeep. It runs on the existing self-hosted Unraid box alongside CI and the user’s other services, so it must also be cheap and self-healing.
The demo lives in a separate private repo (artist-alley-demo) as a
deploy bundle; this ADR records the architecture, not the app code.
Decision
Section titled “Decision”-
Read-only at the edge, not the app. An nginx front door allows only
GET/HEAD/OPTIONSon/api/v1/*(vialimit_except, the safe idiom), allow-lists/auth/login+/auth/logout, and returns a friendly JSON 403 on any write. The app is never published to the host — only nginx is reachable — so nothing bypasses the edge. -
Defense in depth via the role, not a superuser. The public account gets a
demo-viewerrole with the.readcaps + coarse.admincaps needed to render every admin surface, but deliberately notsystem.admin. So even if a write slipped past nginx, the app’s own per-capability guards still refuse it — two layers, not one.Amended 2026-07-21 (v0.5.2 / #474). The
.readcaps gate rows, not the content plane — so the demo-viewer could listteam/restrictedassets but not read their bytes (visibility.CanReadContent, ADR 0064), leaving ~85% of the seeded catalogue as blank “Preview unavailable” tiles. The fix stays true to this principle: the role now also holdscontent.read.all(migration00014) — a content-plane-only read cap honoured solely inCanReadContent, granting asset-byte reads at every sensitivity tier and nothing else.system.adminwas the wrong lever precisely because it is the superuser wildcard this point rejects: on a public login it would expose every admin surface, not just content. The grant lives in the demo deploy bundle’sprovision-demo.sql, not in the migration (the cap is defined in core; granting it to a role is a deploy-side provisioning step). -
Secure bootstrap — no default admin.
AA_BOOTSTRAP_DEFAULT_ADMINis off, so the app creates the first admin with a random password (never the documentedArtistAlleyMogul). The deploy script recovers that password from the boot log internally to seed + provision; it is never exposed. Onlydemo/demois handed out. -
No real secrets. Blank SMTP / AI / SSO / federation; throwaway
AA_MASTER_KEY/AA_SCRAMBLE_KEYgenerated on the box. The admin config screens render empty forms — the feature story with zero exposure. -
Env-gated demo affordances.
AA_DEMO_MODEsurfaces ademo/demosign-in hint + a read-only banner; off by default, zero footprint on real installs. -
Host-side auto-update, not CI. GitHub runners are containers without host access to the Compose-Manager stack, so the update trigger lives on the host: an Unraid User Script polls the registry and, when the
:latestdigest advances (a tested, tagged release), runs a reset that rebuilds + reseeds. State is nuked freely — it’s read-only, nothing to lose. -
Exposure via the existing Cloudflare Tunnel. The bridge-mode tunnel container can’t reach
127.0.0.1, so nginx binds the host LAN IP; a cache-bypass rule keeps the dynamic app from being edge-cached.
Consequences
Section titled “Consequences”- The demo showcases the full surface with no mutable or privileged access, and no bespoke “demo build” — it runs the exact release image.
- Pre-1.0 schema churn is handled by nuke-and-reseed on each release, so a breaking migration can’t leave the demo wedged.
- Cost: the demo depends on host-side plumbing (User Script cron + tunnel)
that isn’t captured in a GitHub workflow — intentional, since runners can’t
reach the host. Documented in the
artist-alley-demoREADME. - A per-IP rate limit was deliberately skipped: a read-only, media-heavy demo would false-positive on normal browsing; Cloudflare’s baseline covers abuse.
References
Section titled “References”- ADR 0058 — two-tier demo seed dataset (Layer A public content the demo loads).
artist-alley-demorepo — the deploy bundle (compose, nginx edge, reset + provision scripts, host auto-update).