Skip to content

How it works

A thin, opinionated layer over boring, proven pieces — and the reasoning behind each choice.

The pieces

PieceHolds
The app (Node, Express, tRPC) Identity, community structure, permissions. The only thing users talk to.
PostgreSQL Accounts, communities, channels, message index, file metadata, roles, bans.
Dendrite (Matrix homeserver) Message transport. Every message is a real Matrix event. Stores its rooms in the same PostgreSQL instance, so there's one database to back up rather than two.
Kubo (IPFS) The actual bytes of every shared file.
cloudflared (optional) Outbound-only tunnel, when you want a public address.

Either as Docker containers on one network, or as systemd services on one machine. Same architecture, different packaging.

Words used precisely

These overlap badly if left loose, so throughout the docs:

TermMeans
Instance One deployment — app, database, homeserver, IPFS — on one operator's hardware. The unit of sovereignty.
Community A space inside an instance. What Discord calls a server.
Channel A room inside a community.

"Server" is avoided because it means both the machine and the Discord-style community. The database still calls communities servers — renaming tables is a migration with real risk and no user-visible benefit, so the schema keeps the old name and the documentation carries the distinction.

A community is a Matrix Space

Creating a community creates a Space on your homeserver. Every channel inside it is a Matrix room, linked as a child of that Space — starting with #general, created for you.

This isn't decoration. It means a third-party Matrix client like Element can connect to your homeserver and see the same rooms, the same messages, the same power levels. Your community isn't trapped in this particular app.

The browser never talks to Matrix

This is the load-bearing decision, and it's worth explaining because most Matrix clients do the opposite.

The app provisions one Matrix account per user on first use and holds that access token server-side, in the database. It never reaches the browser. Every Matrix operation — sending, editing, reacting, joining, redacting — goes through the app, acting on the user's behalf over the internal network.

Three things fall out of that:

The cost is honest: the app is now on the critical path for every message, and it can read them. See security for what that means.

Instances negotiate before they connect

A client talks to several independent instances, each run by a different person on a different upgrade schedule. So before offering a feature it asks what the instance can actually do:

GET /api/instance      what this instance is and can do
GET /api/capabilities  the same, cheap to poll
GET /api/version       versions, for humans
GET /health            is the app alive?
GET /ready             are its dependencies?

All unauthenticated, because a client connecting to an instance it has never seen has no credentials yet. None of them expose members, channels, or messages.

Every capability defaults to absent. An instance that has never heard of a feature must read as "doesn't have it", never as "probably fine" — optimistic defaults are how a client ends up offering something that silently does nothing. When a capability is missing the client explains why rather than hiding the button, because someone whose friend's instance has no voice should learn that instead of wondering where it went.

/health deliberately doesn't touch the database. A liveness check that fails when Postgres fails can't distinguish "the app is down" from "the database is down" — the first question at 3am — and it makes orchestrators restart a perfectly healthy process.

The protocol is versioned separately from the application, because requiring independently operated instances to move in lockstep with our releases would make every one of them quietly dependent on us. Compatibility is one rule: the same protocol major version. Application versions never gate a connection. The specification →

Files

Uploads go to your own IPFS node and are pinned there. The file's content identifier and metadata are recorded in PostgreSQL against the channel.

Downloads stream back through the app, not from a public IPFS gateway — so membership is checked on every request and content identifiers never leak to people outside the channel. Uploads cap at 50 MB.

Accounts: local, or one across every server

Every server issues its own accounts, and always will — a server with no connection to sovrgnnet.cc works completely.

Optionally, an operator can also accept sovrgnnet.cc accounts, so one sign-in works across every server that opts in. That is centralization, in the part of a system where it matters most, and it's worth being plain about the trade rather than selling it:

One identity doesn't mean one face: each server keeps its own nickname and avatar, so the same account can be "Zach" in one community and "chronus" in another.

Authority, in two layers

SOVRGNnet keeps its own roles — owner, admin, moderator, member — in PostgreSQL, ranked, and checks them on every operation. That's the authoritative layer.

Matrix power levels are kept in sync as a best-effort mirror, so third-party clients show a sensible picture. But the app never trusts the homeserver's opinion about who's allowed to do what. If the two ever disagree, the app's answer is the one that counts.

Nothing listens on your router

When you want a public address, it runs over an outbound tunnel: your machine connects out, and traffic comes back down that connection. No forwarded ports, no exposed home IP, nothing for a scanner to find.

The landing site you're reading is deliberately separate — static files on Cloudflare Pages, so this page stays up even when the homelab doesn't.

What's deliberately not here

What's honestly missing

The engineering-detail version lives in ARCHITECTURE.md, and the ordering of what's next is in ROADMAP.md.