Skip to content

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:

Terminal window
NOVA_SANDBOX_RUNTIME=wasm # default. runtime/nova-wasm — nothing to install
NOVA_SANDBOX_RUNTIME=nova # runtime/nova-sandbox: Linux namespaces, cgroups v2, seccomp, Landlock
NOVA_SANDBOX_RUNTIME=docker # the image below

nova-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):

Terminal window
docker/sandbox/build.sh # or build.ps1 on Windows — ~3-6 min first build, ~1.1-1.3GB image

The 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 1

Whichever 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:

PathWhat it isWhere
Vercel + Cloudflare Tunnel (chosen, PLAN.md §8.2)Static web + docs on Vercel, the API on a machine you control, reached through a Cloudflare TunnelVercel + Cloudflare production
Docker Composecaddy (automatic HTTPS) fronting api, web, docs containers, with named volumes for SQLite and the workspacedeploy/docker-compose.yml, deploy/Dockerfile.{api,web,docs}
Fly.ioOne fly.toml per app (api, web, docs)deploy/fly/
A bare VPSA single setup scriptdeploy/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/relay is 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:

Terminal window
curl -s https://relay.novaterra.world/relay/hello

admits 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:

Terminal window
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:

reachabilitywhat it meanswhat to do
reachablea mailbox is open; mail arrives while the machine is offnothing
unconfiguredno relay at all — NOVA_RELAY_ENDPOINT=none, or a plain pnpm dev checkoutnothing, unless you want one
refusedthe relay answered, and said noread lastError; the words are the relay’s
unenrolleda relay is configured and no mailbox is open yet, for a reason that is not a refusalusually the first minute after a boot; wait
unreachablethe relay did not answer at allnothing; 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.