Under the hood · D&D 2024 (5.5e)

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

The welcome screen with five starter scenes
Real screenshot from the app.

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.

The asset shelf with photoscanned props, furniture and lights

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.

Questions

Do I need a separate database server to try this?

No. Without a `DATABASE_URL` set, it runs on PGlite — Postgres in-process — under `data/pg/`. Set `DATABASE_URL` to point at a real Postgres instance for production.

Where do uploaded models get stored?

Cloudflare R2 or a plain directory on disk (`data/blobs/`), your choice.

Are Premium features gated on a self-hosted copy?

Only if you turn billing on. By default there's no billing at all, so importing your own models and keeping many saved scenes are open on every account.

Does shared-room play work behind my own reverse proxy?

Yes, as long as the proxy passes WebSocket upgrades through — Caddy and nginx do this by default.

Ready to put a map on the table?

Sign up in a minute, no card needed. Five starter scenes are waiting on the welcome screen.