← All work

Frostcord

Self-hosted team communication with end-to-end encryption

Role
Lead developer on Frostforge’s independent fork of Sharkord (MIT). Desktop app, licence server and provisioning API written from scratch; around half of the server and web client; release engineering and the legal surface.
Period
2025 - present
Status
In production
Stack
TypeScript · React · Node.js · tRPC · MediaSoup · SQLite · Drizzle · Electron · Capacitor
1 call
provisions a licensed instance
230
permissioned RPC procedures
1
binary the customer runs, no reverse proxy
4
client targets from one monorepo

A communication platform of the size people actually expect: messaging with threads and scheduled delivery, WebRTC voice and video through an SFU, roles and moderation, a plugin system, encrypted direct messages, four client targets and nine languages - running on hardware the customer controls.

01The problem

Frostcord is Frostforge’s fork of Sharkord, an MIT-licensed platform whose repository starts five months before my first commit. What I added is the product around it: the desktop application, the licence server and the provisioning API, written from scratch, plus roughly half of the server and web client, the release engineering and the legal surface. The problem it answers was already the right one.

Teams that cannot put their internal conversations on a third-party platform - regulated industries, public bodies, anyone with a data residency requirement - are left choosing between self-hosted tools that feel a decade old and SaaS they are not allowed to use.

The self-hosted options that do exist tend to fail at installation. They assume an operator who is comfortable with a reverse proxy, a database, a TURN server and a certificate chain. That operator does not exist at a 20-person company.

02The scope of it

The product documentation runs to 184 articles across fifteen sections, and exactly fourteen of those articles cover self-hosting. The other 170 are the part people use: messaging, voice and video, direct messages, servers and channels, moderation, security and privacy, friends and presence, plugins, user settings, and one section each for the web, desktop, mobile and mobile-web clients.

Underneath: an HTTP server for auth and uploads, a WebSocket layer over tRPC carrying 230 procedures behind permission middleware - 114 mutations, 66 queries and 50 live subscriptions, a MediaSoup SFU, SQLite behind Drizzle that migrates itself on startup, a pub/sub layer, a plugin runtime, and cron jobs for cleanup, expiring messages, scheduled delivery, checkpoints and backups.

03Messaging

A Tiptap composer with Markdown, syntax-highlighted code blocks, spoilers and mentions. Inline replies, threads, edit history that keeps every previous version, bookmarks collected across all channels, and search over content and filenames.

Scheduled messages are delivered server-side, so they arrive whether or not the author is still connected. Auto-deletion expires messages after a day, a week or a month through a job that runs every minute. Reactions cover the full Unicode set plus custom server emoji.

04Voice and video

WebRTC through a MediaSoup SFU rather than a mesh, so a call does not degrade with the fourth participant. 1080p60 by default, screen sharing, webcam, per-user volume, and a transport cap so one client cannot saturate the link.

Noise suppression runs client-side through DTLN and RNNoise. VP9, VP8, H.264 and AV1 for video, Opus for audio.

05Encryption, and what it does not cover

Direct messages can be end-to-end encrypted by mutual consent: X25519 ECDH for key agreement, AES-256-GCM for message bodies, HKDF-SHA256 for derivation, and a per-conversation group key that rotates its epoch whenever membership changes. If a current key is unavailable the client refuses to send rather than falling back to plaintext.

Key backup is done in the browser with PBKDF2 at 600,000 iterations before anything is uploaded, so the server holds a blob it cannot open. A 60-digit safety number derived from both public keys lets two people verify out of band that nobody is in the middle, and it changes if a key does.

The interesting part is the documentation of what this does not protect. Attachments are not encrypted - the server sees their bytes, names and sizes, and the interface says so. The epoch key gives no per-message forward secrecy. Sender attribution stays server metadata rather than a per-message signature. A compromised device is outside the boundary entirely. All of that is written down, because an encrypted-messaging feature whose limits are undocumented is worse than one that makes no claim at all.

How an encrypted conversation key is establishedEach participant holds an X25519 key pair. A random group key is generated for the conversation and wrapped separately for every participant using an ECDH shared secret and HKDF. Messages are encrypted with AES-256-GCM under the group key. The server stores the wrapped keys and the ciphertext, and can open neither; a membership change rotates the epoch and the group key is wrapped again.AliceX25519 key pairBobX25519 key pairGroup keyrandom, per conversationwrapped for AliceECDH + HKDF-SHA256wrapped for BobECDH + HKDF-SHA256MessageAES-256-GCM · fresh IVWHAT THE SERVER STORES · AND CANNOT OPENwrapped keysciphertextpublic keysprivate keys and the group key never leave the participantsmembership change→ new epoch → rewrap
The server routes messages it cannot read, and holds key material it cannot unwrap. What it can still see is listed on the case study, because a boundary is only useful if both sides of it are stated.

06The machinery that makes it sellable

A chat platform is not a product until somebody can buy one. The licence server and the provisioning API are the part of this that is mine outright - about eleven thousand lines with their own test suites - and they are what turns a repository into an instance a customer pays for.

Licences are validated against a signed response, not a boolean. Each answer carries the plan, the expiry, the seat count and a machine binding, signed with a key the server refuses to load unless it is a regular file under 64 KB opened without following symlinks, and unless the private half is mode 0600. The response is bound to the request by nonce and timestamp with a five-minute skew window, so a captured answer cannot be replayed at another machine later. The heartbeat is signed the same way.

Provisioning is one call. It reserves a slot under an operation lock, builds a Proxmox container, installs packages behind temporary firewall rules, hardens the container, injects the licence, writes a Caddy route, creates the DNS record and waits for it to go proxied, assigns a WebRTC port, waits on a health check, and registers the instance for high availability - with ownership markers so it can never adopt a container it did not create, and a removal path that undoes all of it in the right order.

07Running a server

Roles carry 35 granular permissions with per-channel overrides, custom colours, hoisting and drag-and-drop ordering. Moderation covers timeouts, kicks, bans, slowmode, channel archiving, bulk deletion across every channel and DM, and device-fingerprint checks against ban evasion. An audit log records thirty-eight kinds of event.

Security is layered, never assumed: TOTP two-factor with rate limiting, email verification, Turnstile, VPN and proxy blocking, geoblocking, Argon2 password hashing, signed updates the binary verifies against itself, and an admin token required before the first account can be created.

Four client targets from one monorepo: the web client, an Electron desktop app with multi-server folders, a tray, deep links and an integrity check, and native Android and iOS builds through Capacitor. Nine interface languages.

08How it ships

All of that arrives as one executable. The operator downloads a file, runs it and opens a browser - no separate front-end deployment, no container orchestration, no database to provision, no reverse proxy to reason about before the first message is sent.

The single component deliberately kept outside that process is the plugin runtime: each plugin runs as its own subprocess and talks to the server over IPC.

What ships inside the Frostcord binaryFour clients - web, Electron desktop, Android and iOS - connect to a single executable that contains the HTTP server, the WebSocket and tRPC layer with over 140 endpoints, a MediaSoup SFU for voice and video, an SQLite database that migrates itself, and the pub/sub and cron layers. Plugins are the one component outside the binary: each runs in its own subprocess and talks to it over IPC.ONE EXECUTABLE · NOTHING ELSE TO INSTALLWebbrowserDesktopElectronAndroidCapacitoriOSCapacitorone portHTTP serverauth · uploads · web clientWebSocket / tRPC140+ endpoints · subscriptionsMediaSoup SFUvoice · video · screen shareSQLite via Drizzlemigrates itself on startupPubSub · croncleanup · scheduled · backupsPluginown subprocessIPCoutside on purpose
Everything an operator has to install is inside the dashed line. Plugins are the one thing kept out of it, in a process of their own - a plugin system without isolation is a remote code execution feature with better marketing.

09Decisions worth explaining

One binary instead of a deployment guide

Every additional installation step is a place where an evaluation ends. Bundling the built client, the SFU and the database into the server binary removes the entire class of "works on my machine" support requests, at the cost of a larger artifact and a more involved build pipeline. For self-hosted software, that trade is not close.

The build script is tested like code

A release build is where a supply chain gets compromised quietly, so the invariants of the build are asserted rather than remembered: it installs from the reviewed lockfile, it never pipes a remote installer into a shell, it refuses to run without the signing keystore password, and it cannot substitute an unsigned debug Android artifact for a signed release. The Electron toolchain versions are pinned by assertion too, and a blanket sandbox override cannot reappear in the packaging config. None of that survives on discipline alone; the test fails if the script drifts.

Breaking the old crypto formats on purpose

An earlier pairwise and sender-chain scheme was not kept for compatibility. The current build neither emits nor decrypts it, and the migration deletes its ciphertext, key rows and backups outright. Keeping a weaker format readable "just for old messages" means shipping both forever and defending the weaker one; so the choice is to break it, say so, and require server and client to move together.

A plugin SDK with a real sandbox

Self-hosted customers ask for changes no roadmap will contain, so plugins get an SDK, commands, event hooks, settings and UI slots - and their own subprocess, talking over IPC rather than sharing the server process. A plugin system without isolation is a remote code execution feature with better marketing.

Questions about this project?

I am happy to walk through the architecture, the parts that did not work, or the code itself.