Playground (demo mode)
Run a public, no-signup demo instance — one-click sign-in with published demo credentials, outbound/destructive endpoints blocked, and the whole workspace wiped + reseeded from a template on a timer.
Demo mode turns an instance into a public playground: visitors sign in with one click (no signup), poke at a fully seeded workspace, and everything they did is wiped on a timer. It powers the “try the live playground” funnel on the website, and works the same on any self-hosted deploy.
Enabling
Section titled “Enabling”Set the env vars and (re)start the instance:
| Var | Meaning |
|---|---|
DEMO_MODE | 1/true turns the playground on. Never set it on a real instance — it publishes the admin credentials by design. |
SEED_TEMPLATE | Schema-template id (e.g. ecommerce, blog, crm) the workspace is reseeded from after every wipe. |
DEMO_EMAIL | Demo-admin email shown on the sign-in screen. Default demo@backlex.com. |
DEMO_PASSWORD | Demo-admin password (public by design). Default playground. |
DEMO_RESET_MINUTES | Minutes between wipes. Default 60. |
No manual provisioning is needed: on a brand-new database the first cron tick
bootstraps the demo admin, applies SEED_TEMPLATE, and the instance is ready.
What visitors get
Section titled “What visitors get”- The public auth surface (
GET /api/auth/providers) carries ademo: { email, password }field, so the sign-in screen shows a one-click Enter the playground button. - The admin shell shows a persistent banner (“shared demo — resets every hour”) so nobody mistakes it for a private instance.
- The demo account is a normal instance admin: collections, items, flows, forms, dashboards, API keys, GraphQL, MCP — all real.
What’s blocked
Section titled “What’s blocked”Writes to endpoints that could send outbound traffic, break the instance, or
lock everyone out of the shared account return 403 (“This action is disabled
in the playground”):
- email / SMS / push config and sends (
/api/admin/email-config,/api/admin/sms-config,/api/admin/push-config,/api/messaging/*) - auth config + SSO (
/api/admin/auth,/api/admin/saml,/api/admin/ldap-config,/api/admin/platform-*) - auth hooks (
/api/admin/auth-hooks, and the GraphQL/MCP twins through the service) — asend-emailhook receives every end-user magic link and one-time code, and every playground visitor is an admin. Sync and auth hooks are also wiped on reset, so none outlives it - external-DB migrations (
/api/admin/migrate) and raw SQL (/api/admin/db) - demo-account takeover (
/api/auth/change-password,change-email,delete-user,two-factor)
Reads on all of those prefixes stay open so the admin pages render. The block
list lives in apps/web/src/server/services/demo.ts (isDemoBlockedRequest).
For abuse control beyond the guard, the usual knobs apply: API_RATE_LIMIT_*
and USAGE_LIMIT_*.
The reset
Section titled “The reset”resetDemoWorkspace (driven by the scheduler; also POST /api/admin/demo/reset
for a manual wipe) converges the instance back to its seeded state:
- drops every managed collection’s physical table + all collection metadata,
then every managed table (
c_<12 hex>_<slug>) still on disk that no collection row points at — an orphan would otherwise survive every reset and make the template skip its collection for good, - best-effort deletes stored file objects, then truncates every visitor-state
system table (flows, webhooks, api keys, app users, forms, dashboards,
settings, …) — keeping the scheduler’s
__sweep__*watermarks, - deletes every workspace except the default one and every user except the demo admin — recreating the admin with the published password if a visitor changed or deleted it,
- re-applies
SEED_TEMPLATE(collections, sample rows, roles, dashboards). An apply that throws, or that skips any collection on the freshly wiped workspace, fails the reset — so it is retried (below) instead of leaving a partly seeded playground for the whole interval.
The last-reset timestamp is persisted in app_settings, so the cadence
survives isolate restarts; each isolate re-checks it at most every 5 minutes.
Visitors signed in when the wipe lands keep their cookie but the shared
account’s state resets under them — by design.
The timestamp is claimed before the wipe so concurrent isolates back off
mid-reset. If the reset then fails, maybeResetDemo hands the claim back with a
DEMO_RETRY_BACKOFF_MS (5 min) delay rather than sitting on a half-wiped
workspace for the rest of the interval — a broken playground self-heals minutes
after the cause is fixed instead of at the next hour boundary.
Ops notes
Section titled “Ops notes”- The playground is just a normal deploy (Workers, Bun, Vercel, Netlify) with the env vars above — nothing else is special about it.
- Link previews (Cloudflare only). A shared playground link unfurls on
LinkedIn, Slack and X with a title, description and image, because the
Worker adds Open Graph tags to the landing page when
DEMO_MODEis on. That needs"/"inrun_worker_first—wrangler.playground.tomlhas it — since Static Assets otherwise answers/before any code runs. The document keeps exactly the static shell’s headers, and an instance withoutDEMO_MODEgets its shell back unchanged, so the route is safe in any config. Other targets serve the plain shell, which carries no card. - Storage blobs whose metadata rows were wiped past the per-reset cleanup cap (1000 objects) are orphaned; point the playground at a dedicated bucket.
- Keep the playground’s own database migrated. A dedicated deploy has its
own D1/Postgres, and schema drift there is silent until a reset dies mid-way
(a wiped workspace whose demo admin never gets its role back → an empty admin
UI).
migrate-d1.tstakes the database name from the--configfile’s first[[d1_databases]]entry, so the deploy’s own wrangler config is enough:bun run packages/db/src/sqlite/migrate-d1.ts --remote --config=apps/web/wrangler.playground.toml. (wrangler d1 execute <name>resolves the name against the account, not the config — a staleD1_DATABASE_NAMEoverride silently migrates a different database and still exits 0.) - Tests:
apps/web/tests/settings/demo-mode.test.ts, andapps/web/tests/runtime/playground-share-card.test.tsfor the link preview.