Self-hosting your virtual tabletop: what it actually takes
"Self-hosted" sometimes means a sprawl of containers, queues and a separate object store to wire up before anything renders. This one is a small, ordinary Node app: one process, one database, one deploy — the same code that runs the hosted product, minus the hosted product's accounts and billing.
· 6 min read · TerrainTale

One process to run it
`npm run build` bakes the site URL into canonical URLs and the sitemap, then `npm start` runs `node server/index.mjs`, which serves the built app, the API and the sign-in gate together — one process, not a frontend server and a backend server that both need to be kept running and in sync. Put it behind any reverse proxy that passes WebSocket upgrades through — Caddy and nginx both do this by default — for TLS.
Data stays where you put it
Everything lives in Postgres: a real `DATABASE_URL` in production, or PGlite — Postgres running in-process — under `data/pg/` when none is set, so a fresh checkout needs no external database server to get started. Uploaded models go to a blob store, either Cloudflare R2 or a plain directory (`data/blobs/`) on disk. Backing the whole thing up is `pg_dump` plus whichever blob location you chose — no separate backup tooling to learn.

Sign-in without depending on a hosted account
Email and password, one-time email links, and Google, Facebook, Apple, GitHub and Discord sign-in are all handled by the same server, with no third-party SDK required. `ALLOW_SIGNUP=0` or an `ALLOWED_EMAILS=…` list turns a public deployment into a private table restricted to your own group — useful for a self-hosted copy meant for one table rather than the open internet.
No billing unless you turn it on
With no Creem key configured, no mock billing, and `PLANS_ENFORCED` unset, billing is off entirely: every account is unlimited, the paywall dialog never appears, and the account page reports 200 scenes free. That's the self-hoster's default and what CI runs against — the whole product, including importing your own model files, is open on every account on a self-hosted copy unless billing is explicitly switched on.
What you're actually committing to managing
In practice: keeping the Node process running (a process manager or container restart policy), a Postgres instance if you outgrow PGlite, disk space or an R2 bucket for uploaded models, and a reverse proxy in front for TLS and WebSocket passthrough for shared rooms. That's the whole list — there's no separate search index, queue, or cache tier to stand up alongside it.
Who this is actually for
Self-hosting makes the most sense for a group that already wants control over its own data, or a DM comfortable running a small Node service the way they might run any other self-hosted app — a private table restricted to friends via `ALLOWED_EMAILS`, a group that doesn't want scenes and accounts living on someone else's server, or a tinkerer who wants to read and modify the code that runs their table. It's the same product as the hosted site, not a stripped-down variant, so nothing about switching to a self-hosted copy means giving up features.
It's not the right fit for a table that just wants to sign up and start playing tonight without managing a server at all — that's exactly the case the hosted product's free plan is built for, and the two aren't mutually exclusive: a scene exported as JSON from a self-hosted copy imports on the hosted site, and vice versa.