← Melih Sahtiyan
14 min readGathera

Building my own Discord: voice, screen sharing and real-time chat on one free ARM server

A Discord-like real-time communication platform. When I reopened the half-finished 2025 project a year later, messages didn't even reach the screen; within a week it was live on a free ARM server, with voice, 1080p screen sharing, rotating sessions and a single access rule.

1 / 6

Server page: boards, voice rooms, recent activity and members (dark theme).

In short

  • What I built: A Discord-like real-time communication platform: servers, text and voice channels, one-to-one and group DMs, DM calls, file uploads, GIFs, 1080p screen sharing and a soundboard. The backend is Node.js + MongoDB, the client React + Electron, and voice goes through a LiveKit server I host myself.
  • Where it started: A project I began in summer 2025 and left half-finished. When I reopened it in October 2026, channel pages were stuck in an endless request loop and messages never reached the screen.
  • Within a week: voice and screen sharing, refreshing sessions, a single access rule, unread tracking and scroll-back were in, and the app was live on a free ARM server with Docker Compose.
  • The constraint: Everything has to run on one free Oracle Always Free ARM machine (2 cores, 11 GiB) that other services also run on. I pinned every Gathera container to one core and gave each a memory cap.
  • The most interesting problem: adding only the shared app's sound to a screen share on Windows, without sending the call's own voices back to everyone.
  • Honest status: there is no permanent automated test suite in the repos. Every change was checked with scripts against the running system, but those scripts never made it into the repo.

At a glance

TypeSide project · sign-up by invite code only
RoleSolo: product decisions, architecture, backend, desktop client, deployment
TimelineFirst version July–August 2025; picked up again in October 2026, ongoing
BackendNode.js · TypeScript · Express 5 · MongoDB (Mongoose, replica set) · Socket.IO · InversifyJS · sharp
ClientReact 19 · Vite · Tailwind · Redux Toolkit · Electron 37 · livekit-client
InfrastructureDocker Compose · Caddy (automatic HTTPS) · self-hosted LiveKit SFU · Oracle Always Free ARM
Size~13.6k lines of backend TypeScript, ~23k lines of client (React + Electron); two repos, 60 commits

The problem

The goal was a communication platform people would actually open every day, running on my own server. That is a lot more than "a demo that sends and receives messages": messages have to arrive without a reload, voice can't drop, a screen share has to show a game at watchable quality, a session can't fall over on every reload, and sign-up has to be invite-only.

Writing a Discord-like app from scratch puts nearly every hard part of real-time systems into one project: ordered message storage, push events, WebRTC, authentication, file storage and deployment.

The constraints

  • Zero budget. My first plan was a small Hetzner server (2 vCPU / 4 GB for ~10 users). I then moved to a free Oracle ARM machine I already had. A second free machine wasn't possible: that one machine had used up the account's limit.
  • A shared server. Other services run on the same machine, and Gathera must not slow them down.
  • The main client is a Windows desktop app. It is packaged with Electron, and the same code has to work in a browser too.
  • Invite-only sign-up. There is no public sign-up; creating an account needs an invite code.

Architecture

  Electron app / browser  (React 19 + Redux)
     │ REST            │ Socket.IO          │ WebRTC (voice, screen share)
     ▼                 ▼                    ▼
┌──────────────── Caddy · HTTPS (Let's Encrypt) ─────────────────┐
│  /api/*  ─────────────► backend      /livekit/rtc* ──► LiveKit │
│  any other path ──► 404                                        │
└────────────────────────────────────────────────────────────────┘
 
 backend · Express 5 + InversifyJS
   presentation → application → domain ← persistence
   access.ts            one rule for who may read a channel
   Socket.IO            a room per user: user:<id>
   uploads              random names, signed links that expire in 24 h
        │ internal network only                │ 10-minute join token
        ▼                                      ▼
 MongoDB (single-node replica set)        LiveKit SFU
   users · servers · channels               voice · screen share
   chunks → messages · readstates           data channel (soundboard)

The backend follows Clean Architecture, split into presentation, application, domain and persistence layers, with dependencies injected through InversifyJS. MongoDB runs as a single-node replica set because the code uses transactions, and it is never exposed: only the backend's internal network can reach it.

Key decisions

1. Storing messages in "weighted chunks"

Each channel's messages live in a linked list of chunks, and a new message always goes into the last one. A chunk is full once its weight reaches 100, and the weight depends on the message type:

TypeWeight
Text1
File5
Audio · GIF10
Image20
Video33

That keeps the cost of loading a page predictable: a chunk can hold 100 text messages or just three videos, so pages in a media-heavy conversation get shorter on their own. Deleting a message doesn't lower the weight; the weight's only job is to decide when the next chunk starts.

The idea is from 2025, but a sound design didn't mean sound code. The moment a chunk filled up, four separate bugs fired: the new chunk was added to the channel twice, the old chunk's nextChunkId was overwritten with null, the message that started the new chunk kept the old chunkId and vanished from view, and the channel's lastChunkId never moved past the first chunk. Every one of them only appears after the 100th text message, which a small demo never reaches.

Building scroll-back, I made one more call: I didn't trust the previousChunkId links in older data. Paging finds chunks by channel and creation order instead of following the links, and gathers whole chunks until a page has more than 40 messages. I tested it with 505 messages across 6 chunks, including a 200-message burst arriving while the page was open. Jumping to an old pinned message or quoted reply that isn't loaded yet takes the same path: pages load until the message is found, then it is highlighted.

2. Authorization as one rule, not one check per endpoint

This was the most serious gap when I picked the project back up: GET /messages/:chunkId and POST /messages never checked whether the user could read the channel. Anyone who knew a chunk id could read its messages.

I didn't fix it by adding an if to every endpoint but by writing one rule (access.ts): public server channels can be read by the server's members; private channels, DMs and groups only by their own members. Channel and server reads, invites, reading and sending messages, uploads, pins and live events all use that rule, and a refused upload's file is deleted. A second leak closed along the way: live events from private channels now go only to the channel's members, not to the whole server.

3. A self-hosted SFU for voice

In mesh WebRTC every participant sends their stream to every other participant separately, so sending a 1080p screen share to several people over a home upload link isn't possible. An SFU receives the stream once and forwards it to the viewers. I run LiveKit myself in the same Compose stack rather than as a cloud service, so it costs nothing extra.

The backend hands a 10-minute join token only to members of the server or DM. The token allows the microphone, screen sharing, screen-share audio and a data channel for the soundboard, and nothing else.

4. Screen sharing: watching is opt-in

Shares are published with LiveKit's 1080p 30 fps preset (up to 5 Mbit/s), plus a second 960-pixel-wide, 1.25 Mbit/s layer for small views.

With slower computers in mind, I made watching opt-in. Voices are subscribed by hand rather than automatically (autoSubscribe: false). A share shows up only as "X is sharing their screen · Watch", and nothing is downloaded or decoded until someone presses Watch; the ✕ (stop watching) unsubscribes again. I checked in LiveKit's logs that the share was subscribed only after Watch and unsubscribed after stop watching.

5. Putting the right sound on a screen share

Sharing the computer's sound as-is has a price: the call's own voices get captured too and are sent back to everyone, which makes an echo.

The fix was Windows' WASAPI process loopback (in Electron's main process, via the loopback-capture package):

  • When a window is shared, the process that owns the window is found and only that app's sound is captured.
  • When a whole screen is shared, everything is captured except Gathera's own process tree, so the call's echo never forms.

The 48 kHz 16-bit stereo PCM is passed to the page through an AudioWorklet and published as the share's stereo audio track. With a test app I confirmed that window capture picked up only that app's sound and that excluding a process tree really dropped its sound. In a DM call, LiveKit showed the share audio as ~70 kbit/s stereo Opus with no loss, subscribed only once the viewer pressed Watch. This works on Windows 10 2004 and later only.

6. Sessions: short-lived access tokens, refresh tokens that rotate on every use

In the old version the token refresh and logout methods only threw "Method not implemented", so once a token expired the user had to sign in again. The setup now:

  • Access tokens live 15 minutes. Refresh tokens rotate on every use, and reusing an old refresh token ends that login session entirely, so a stolen token is good for one use at most. Refresh tokens are stored hashed, last 30 days with "Remember me" or 7 without, and expire on their own through a TTL index.
  • On a 401 the client makes one shared refresh and repeats the request; it also refreshes a minute before expiry. Tabs queue behind a Web Lock. Without it, two tabs would use the same refresh token at once, the second use would count as reuse, and the user would be signed out.
  • Sockets follow the same rules. The server closes a socket whose token has expired unless the client sends the refreshed one. After a refused handshake, the client refreshes and reconnects instead of going silent.

On top of that, the backend no longer starts with a JWT_SECRET shorter than 32 characters (the code used to fall back to "default_jwt_secret"), validation errors no longer echo or log passwords, and login and sign-up attempts are rate-limited.

7. Fitting onto one free ARM server

The machine has 2 cores and 11 GiB of memory, and other services run on it too. This is how Gathera fits in:

DecisionWhy
Every Gathera container is pinned to CPU 1CPU 0 stays free for the machine's other services no matter how busy Gathera gets
A memory cap per container (Caddy 256 MB, backend 1 GB with a 512 MB Node heap, Mongo 1 GB, LiveKit 512 MB)A leak or a load spike hits only that container, not the rest of the machine
MongoDB's cache capped at 0.5 GBMongo's default cache aims at roughly half the memory, and this machine doesn't have that much to spare
4 GB of swap, vm.swappiness=10On a sudden spike, processes slow down instead of being killed
Container logs capped at 5 × 10 MBSo logs never quietly fill the disk

The stack went live on 15 October 2026 with a Let's Encrypt certificate. I also wrote down that the memory caps are estimates for now, to be checked with docker stats after the next deploy.

8. Security for an invite-only platform

The server is on the internet, but accounts can only be created with an invite code. These decisions strike that balance:

  • Sign-up requires an invite code. It is checked before the email and username, so without a code nobody can probe which accounts exist.
  • Caddy forwards only two paths: /api to the backend and /livekit/rtc to LiveKit. Everything else, LiveKit's admin API included, gets a 404.
  • Secrets aren't in environment variables but in Docker secret files, so they don't show up in docker inspect or docker compose config.
  • Containers run with read-only file systems, without extra privileges (no-new-privileges) and with Linux capabilities dropped. Mongo sits on an internal network that Caddy and LiveKit can't reach.
  • Per-user limits: 10 messages per 10 seconds, 1 GB of uploads a day, 60 searches a minute and so on. Images over 64 megapixels are refused before they are decoded.
  • Other people's emails appear in no response: search, profiles, friend lists, member lists and message authors now return only public fields.
  • Username search escapes regex characters. A search like (a+)+$ used to produce a 500 or a slow query; now it comes back empty in a few milliseconds.
  • Soundboard plays are built from the sound stored on the server. The URL, name and volume the client sends are ignored; a spoofed play reached the other user as the real "Airhorn", carrying no URL.
  • From a browser, only the desktop app and allowed sites may call the API. I measured that Electron 37 sends no Origin on REST requests and file:// on WebSockets, and built the rule around that. Since scripts that send no Origin can still act like the app, I also wrote down plainly that the real gate is signing in plus the per-user limits.

What only showed up once it ran

None of these appear on an architecture diagram, but to a user each one meant "the app is broken":

What I foundWhat I did
The backend returned lastChunk while the client looked for lastChunkId: the channel page refetched itself forever and messages never appearedMerged the channel and DM pages onto one useChannelMessages hook
includes(server.ownerId) compared ObjectIds by reference, and the check below it was inverted: whoever created a channel was added to its members twiceCompared ids by value; accepting an invite now also adds you to the server's existing channels
Accepting a friend request opened a new DM every timeThe existing one-to-one DM is reused
One soundboard click played the sound three times: the server gave every play a new id, so the copies arriving over the LiveKit data channel and the socket weren't recognisedThe play id is generated on the client and the server keeps it; sounds play only for people in that channel
The soundboard's REST handlers read the user from req.user.id, but authentication sets req.user.userId: listing, uploading, deleting and playing all failedFixed the field and confirmed it with 14 API checks
In development, Vite had no WebSocket proxy for /livekit: LiveKit's handshake got a 200 and an HTML page instead of an upgradeAdded the proxy; the microphone permission is now requested before connecting, so Firefox exposes local ICE candidates
Google shut down the Tenor API on 30 June 2026 and the GIF picker stopped workingMoved to KLIPY. Its terms forbid storing GIF files, so favorites keep references only; GIFs uploaded as files have links that expire after 24 hours, so a copy of those is kept in IndexedDB

Where it stands, plainly

  • There is no automated test suite. Changes were checked with API and socket checks against the running backend (45/45 on the security branch, for example), in the browser and in two desktop clients, but those checks never made it into the repo. There is no permanent net to catch a regression yet.
  • Voice in production hasn't been tested with two clients yet. The latest security hardening is merged but still waiting to be deployed, and the desktop app needs rebuilding for the invite-code field.
  • Everything runs in one process. Sockets, calls and rate-limit counters live in memory. Running several backend instances will need Socket.IO's Redis adapter and shared state.
  • There are no automated backups. That's a deliberate call for now; the manual mongodump commands are in the docs.
  • The client build doesn't type-check. vite build passes, but tsc reports errors, mostly unused imports; one of them was a real bug and has been fixed.

How I worked with AI

When I revived the project in October 2026, I worked with two AI coding agents, Claude Code and Antigravity, at the same time and in the same working copies. That was an engineering problem in itself: two agents can edit the same file, one can "clean up" the other's half-done work, and one switching branches changes the files under the other.

So I gave the project explicit rules:

  1. Re-read a file right before editing it. Make small, targeted edits; never rewrite the whole file.
  2. Changes you didn't make aren't yours to undo. If an unexpected edit breaks your task, stop and ask me.
  3. No checkout, reset, stash or branch switching. When committing, stage only the files you changed.
  4. Check before starting a process or a port, and never kill a process you didn't start.
  5. Claim and close every task in TODO.md: which agent, which session, when, and how it was verified.
  6. Tell me before adding a dependency. loopback-capture, a native module for screen-share audio, went in with my OK, for example.

TODO.md became a dated decision log as a result: who did what, how it was verified and what is still missing, all in one place. Even the security changes were committed on a separate branch cut from origin/main with only their own changes, so the other agent's work in progress couldn't slip in.

Product and risk decisions stayed with me: the deploy target (the existing free server instead of Hetzner), no automated backups for now, approving the new interface design, and what gets deployed when. Some checks only I could do: I listened to the voice join and leave sounds by ear, and watched a screen share from a second client.

What I'm taking to the next project

  • Code written a year ago isn't "nearly done". The first job was to actually run the app and list what was broken; that's the only way I saw that messages never appeared at all.
  • Bugs hide at the boundaries. The chunk design was right; the problems appeared at the moment a chunk filled up. Test data has to cross that boundary deliberately, and many times.
  • Authorization should be a rule, not a habit. Checks added endpoint by endpoint will miss one sooner or later.
  • When two channels carry the same event, the dedupe id has to be born on the client. An id the server generates only recognises copies where the server can see them.
  • On shared hardware, resource limits aren't courtesy, they're a requirement.
  • With more than one agent, the real work is coordination. Without written rules and a shared log, two agents' speed goes into breaking each other's work.