How it works
A thin, opinionated layer over boring, proven pieces — and the reasoning behind each choice.
The pieces
| Piece | Holds |
|---|---|
| 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:
| Term | Means |
|---|---|
| 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:
- Permissions are enforced in one place. Every read and write checks membership and role server-side. A client that lies about who it is gets nowhere.
- The homeserver never needs to be publicly exposed. It listens on loopback. Users reach the app; the app reaches Matrix.
- One identity. You sign in once, to the app. There's no second Matrix login to understand.
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:
- Servers verify sign-ins against a cached public key. sovrgnnet.cc being unreachable blocks new sign-ins; it does not log anyone out and does not take a single server offline.
- Every token names one server and works only there, so whoever runs one server can't replay their users' sign-ins against another.
- Sign-in itself goes through Google, Microsoft, GitHub, or Discord — they have already verified the email address, and it means no password store to breach.
- An operator who wants none of this sets
INSTANCE_ALLOW_SSO=falseand the server is entirely unaffected.
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
- No third-party auth is ever required. Local accounts are rows in your database, passwords hashed with scrypt, sessions in httpOnly cookies. Third-party sign-in exists as an option an operator switches on, never as the only way in.
- No telemetry. The app phones nobody. The desktop client checks for updates on launch, and that's the extent of it.
- No required blockchain. Wallet identity is a possible future option, never a requirement.
- No federation by default. Your homeserver talks to no other server until you turn it on.
What's honestly missing
- Encryption has limits. It's the default only where the deployment supports it, metadata is never encrypted anywhere, and the instance can mint a Matrix device for any of its accounts — the device gets no keys until a person verifies it, but the person has to. See security.
- Live updates poll on some instances. Clients sync directly with the homeserver where it's reachable; where it isn't, they fall back to polling the instance API every few seconds.
- Presence is single-process. Typing indicators and online status live in one app process's memory. Correct for one container; running several would need Redis.
- No voice or video, no password reset by email, no mobile apps.
The engineering-detail version lives in ARCHITECTURE.md, and the ordering of what's next is in ROADMAP.md.