Self-hosting
Novaterra is designed to be self-hosted by the person who owns the world it creates. Everything you need is checked into this repo.
That is not a limitation of the current release. One install is one person’s node, with a single owner enforced in the database, and that is the shape the whole product is built to. A hosted install is the same software with the same single owner — a node that happens to run on rented metal, exportable and movable to your own hardware — and never a tenant row in somebody else’s database. That hosted install exists today: Vocabotics will provision one for you, on its own Fly machine, for £290/year or £29/month. See Hosted or self-hosted for how the two compare, and Hosted nodes and membership for exactly what that £29 buys and how the machine gets built. Everything on the rest of this page is the free path — running the software yourself, on hardware you already have.
“Exportable and movable” is a command, not an aspiration: see Moving your node to your own hardware for how to carry a node’s beings, their addresses and their signing keys to another machine — including off a hosted node and onto your own.
Node profiles
Not every self-hosted install looks the same underneath. Novaterra recognises four node
profiles — seed, leaf, home and hall — that differ in what they run and what they
delegate away. The default install described on this page is a home node: it runs the full
API, the database, the Docker or WASM sandbox, and every scheduled loop, and it is what
pnpm install → pnpm dev gives you. A seed is the opposite end of the spectrum — a small
device, an ESP32 class, that signs and queues but delegates almost everything else to a paired
home. See Node profiles and Seeds for what each profile
runs, what a Seed delegates, and an honest account of what has and has not been measured on real
hardware.
Local development
See Getting started for the full walkthrough:
pnpm install → .env → pnpm dev. Three processes, one machine: Vite on 5173, Fastify on 4000,
Astro on 4321 for these docs.
The code sandbox
code.execute (see Skills catalogue) is the one skill that
runs code a language model wrote, so it gets real containment. The default is WASM, not Docker,
and has been since 6 September 2026:
NOVA_SANDBOX_RUNTIME=wasm # default. runtime/nova-wasm — nothing to installNOVA_SANDBOX_RUNTIME=nova # runtime/nova-sandbox: Linux namespaces, cgroups v2, seccomp, LandlockNOVA_SANDBOX_RUNTIME=docker # the image belownova-wasm runs the language runtime itself — QuickJS and CPython, compiled to wasm32-wasi —
inside wasmtime, behind a hand-written WASI host that has no filesystem path and no sockets behind
any of its imports. About 22 MB on disk, a 5–60 ms start (87 ms measured end to end through code.execute), identical on Windows and
Linux, and nothing to install. What it gives up is stated rather than hidden: no bash, no
numpy/pandas/matplotlib, no npm, and CPU-bound Python about four times slower. See
Sandboxing.
The Docker image, still there and still verified
Reach for it when a project needs bash or the scientific Python stack, or when you install a
plugin that declares the process capability (docker/sandbox/, docker/README.md):
docker/sandbox/build.sh # or build.ps1 on Windows — ~3-6 min first build, ~1.1-1.3GB imageThe run contract is deliberately explicit and lives in one script so it can never drift between the
shell, the docs, and the API’s own invocation
(ensureSandboxImage/runCode in packages/skills/src/code/sandbox.ts, which builds the image
lazily on first use if it doesn’t exist yet):
--network none no network at all--cap-drop=ALL no elevated Linux capabilities--security-opt=no-new-privileges--read-only the image filesystem is immutable--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m the only writable path outside the mount--memory 512m --memory-swap 512m hard cap, no swap--cpus 1Whichever tier you choose, if it cannot isolate then code.execute fails closed: it refuses to
run rather than falling back to the host or to a weaker tier. Set NOVA_SANDBOX_ALLOW_LOCAL=1 in
.env to opt in to the unsandboxed fallback instead, and only then does it run — on the host,
flagged sandboxed: false. See
Security & privacy for why this matters.
Production deployment options
The deploy/ directory holds more than one path to production, built out further than PLAN.md’s own
description of “Vercel + Cloudflare” alone:
| Path | What it is | Where |
|---|---|---|
| Vercel + Cloudflare Tunnel (chosen, PLAN.md §8.2) | Static web + docs on Vercel, the API on a machine you control, reached through a Cloudflare Tunnel | Vercel + Cloudflare production |
| Docker Compose | caddy (automatic HTTPS) fronting api, web, docs containers, with named volumes for SQLite and the workspace | deploy/docker-compose.yml, deploy/Dockerfile.{api,web,docs} |
| Fly.io | One fly.toml per app (api, web, docs) | deploy/fly/ |
| A bare VPS | A single setup script | deploy/vps/setup.sh |
Every path shares the same non-negotiables: SESSION_SECRET/ENCRYPTION_KEY must be real random
values in production, WEB_ORIGIN/COOKIE_DOMAIN must match your actual domains exactly (a
mismatch here is the single most common self-hosting failure — see the cross-subdomain cookie
mechanics on the production page), and the API
itself always needs to be a real, long-lived process — never a serverless function — because of
SQLite, WebSockets, the Docker sandbox, file watchers, and Telegram’s long-polling.
Can other people reach you?
A node running on a machine in your home is, by default, not reachable from outside your own network — no port forwarded, no public DNS record, nothing your router lets in uninvited. That is the ordinary state for almost every residential connection, and it is silent: the app works, the world renders, and a message somebody sent you simply never seems to arrive. There is no error, because nothing failed — the message is queued on their side or held for you at the relay, and your node has not yet asked for it.
Two things work around this, and it matters which one applies to you:
- Collecting. If you can reach them but they cannot reach you, your node can still ask theirs
directly for what is waiting —
POST /api/federation/peer/collect. This is the normal path for one node behind a router talking to another that answers on a real address. - The relay. When neither side can be dialled — two home routers, the common case for two
ordinary people — nothing can be pushed and nothing can be collected from, so there is nowhere to
ask.
services/relayis Vocabotics’ answer to exactly that: a rendezvous a message can be left at for the recipient to pick up later. It carries only sealed bytes it cannot open — see Identity and the mesh for what it can and cannot see.
An installed build attempts to enrol at the shipped relay automatically; a repository checkout
does not, unless you set NOVA_RELAY_ENDPOINT by hand (and NOVA_RELAY_AUTO=on, since enrolling
opens a mailbox and a checkout should not do that by surprise).
Until 2026-09-20 the attempt was not the same as success, and this page said so: the relay required a per-person access code to enrol at all, and almost no install had one. A code compiled into a downloadable installer would be a string in a zip rather than a credential, so none was shipped — which meant every self-hosted node in existence proved its key perfectly and was refused. The node worked, the mail never arrived, and nothing said why.
A self-hosted node now asks for its own admission. POST /relay/admit, once, signed with the key
the node already proves at enrolment, and the relay answers with a per-node document that expires
and can be revoked for one address by name. Nothing to be given, nothing to type, nothing to put
in a file. A node that was handed a code by services/provisioner keeps using it — the relay accepts
both while the code is wound down — so neither kind of node has to know which it is.
You can check what the relay requires today rather than trusting this page:
curl -s https://relay.novaterra.world/relay/helloadmits lists every form it accepts, enrolment names the one a new node should present, and
interim carries the relay’s own sentence about the access code for exactly as long as one is still
accepted. That field going empty is how the interim ends — it is a constant in the code, not a
setting, so an operator who could reword it cannot delete it.
NOVA_RELAY_ADMISSION=off stops your node asking. That is a real preference and not a broken state:
asking tells the relay operator this node exists and intends to use a relay, and an owner may prefer
to be unreachable. Your node only ever asks its own relay — the one compiled into this build, or
the one you named — so a relay cannot point you at a third party to hand your address to.
What a grant does not fix
Admission decides whether you get a mailbox. It does not change what the relay operator can see,
and it would be overclaiming to let it look like a privacy control. In the relay’s own words:
“This relay forwards sealed bytes it cannot open. It learns which two addresses exchanged a
message, when, and how large it was. It cannot learn what was said.” That is true of an open relay,
a code-gated relay and a grant-gated relay alike. The answer to it is that a relay is
replaceable — NOVA_RELAY_ENDPOINT and NOVA_RELAY_DID, one pair, and you are on your own —
not that admission has made the metadata go away. Identity and the
mesh sets out exactly what is
cleartext and what is sealed.
To see which situation you are actually in, ask your own node:
curl http://localhost:4000/api/federation/relay -b "$COOKIE"enrolled: true means mail addressed to you is being held at the relay whenever you cannot be
reached directly. reachability is the field worth reading, because it separates the four states
that used to look identical:
reachability | what it means | what to do |
|---|---|---|
reachable | a mailbox is open; mail arrives while the machine is off | nothing |
unconfigured | no relay at all — NOVA_RELAY_ENDPOINT=none, or a plain pnpm dev checkout | nothing, unless you want one |
refused | the relay answered, and said no | read lastError; the words are the relay’s |
unenrolled | a relay is configured and no mailbox is open yet, for a reason that is not a refusal | usually the first minute after a boot; wait |
unreachable | the relay did not answer at all | nothing; transient |
admission says whether this node holds a signed grant, who issued it, when it expires, and — in
askedAt and lastError — whether it has ever asked and how that went. “Nobody has ever asked” and
“asking did not work” are deliberately different states.
There is a screen for this as well now — Settings → Relay renders the same five states, so a refusal and an outage no longer look alike there either. The endpoint above is still the way to check without a browser.
None of these is a fault. A node that has never been listed anywhere (NOVA_NODE_ENDPOINT unset,
WEB_ORIGIN still loopback) simply is not a failure, and
Finding other nodes says
the same thing from the directory’s side of the same fact.
What’s next
- Keeping it running — supervision, WAL checkpointing, encrypted backups and a restore drill you can run this afternoon. What turns “a person is watching the terminal” into a property of the software.
- Vercel + Cloudflare production — the chosen launch topology, DNS, TLS, the cross-subdomain cookie mechanics, and the smoke test.
- Local-first inference — the plan to move model calls onto your own hardware, and an honest account of what’s built versus what’s still just a plan.
- Finding other nodes — the directory service: a lookup, not an authority, how to run without one at all, and what it means to not be listed.
- Node profiles and Seeds — the four node profiles, and the small-device Seed profile in detail: what it delegates, its measured wire sizes, and what is still inferred rather than measured.
- Moving your node — carrying a node’s beings, their addresses and their signing keys to another machine.
- Scaling and redundancy — four tiers from a laptop to multi-host, and what actually breaks when you run two API processes.