Folds small, scales out — sukhi-fedi
Hello, I'm nyanrus. Let me introduce sukhi-fedi, the software behind the small federated server I run, sukhi.f3liz.casa.
sukhi-fedi is a federated SNS server that speaks ActivityPub. Its main client-facing surface is a Mastodon-compatible API, and it does the JSON-LD and HTTP-signature work itself, in Elixir. Right now it is federating with the wider fediverse. AGPL-3.0, and the current version is v0.4.14.
There is only one thing I really want to say. It is not about a feature, but about the order I built things in. The same codebase runs on a box small enough for a free tier, and when it needs to, it spreads across nodes. And the federated core already works. Let me go through it in order.
The bet I made
When you set up a federated server, most of them make you choose a size first. The big ones can federate with anything, but they want a real server. The light ones fit on a small box, but scaling past that box is not really the plan. I did not want that to be a fork in the road. So the same code folds onto a 1 GB free-tier box, and when it gets tight, it spreads across nodes. That works because the seams live in OTP modules, not in the deployment.
I also wanted one server I could manage in the same way, whether it was small or large.
The other thing I like is the way features scale. The client API is a plugin catalogue. One file is one capability, and it registers itself at boot, so I do not touch a router. Capabilities are grouped into addons, and a set of them can run on its own node. To extend the server, I add a file. I do not patch the core. So new endpoints, and whole groups of features, do not get in each other's way.
None of this is magic. I just put each boundary in one place, and pay for it only once. But because of that, you can start sukhi-fedi as a hobby box, scale it without a rewrite, and extend it without a fork. I think that is not the usual deal.
How I split it
The reason both hold with the same code is mostly here. sukhi-fedi splits into separate OTP applications, one per job.
- gateway (
:sukhi_fedi) — the only entry point for HTTP from people. Login, posting, receiving the inbox, WebFinger. - delivery (
:sukhi_delivery) — reads the outbox and POSTs to remote inboxes. Oban queues also handle the retries. - API plugin (
:sukhi_api) — the Mastodon-compatible REST surface.
The important thing is that these boundaries are drawn in the module structure, not in the network. Each one is an independent OTP app. So I can run them as separate BEAM nodes, or I can fold all three into one BEAM. The boundaries survive even when folded. This is not a layer I added later for speed. It is how I split things from the start.
Between processes it is messages, and state is left to supervision. If I just keep to the plain way of using OTP, then changing the topology is no longer a rewrite. It becomes only a choice of how to boot. Folding small and scaling out are the two ends of the same dial.
Making it small
One of my goals is to run sukhi-fedi on a small box, 512 to 768 MB of memory.
When you layer docker-compose.combined.yml, gateway and delivery become one BEAM (the combined release). It is a setup for a 1-core / 2 GB box. In one 1-core endurance run, this combined stayed flat at ~130 MiB under read, write, and inbox load, all the way through. Even the memory breakdown for a 512 MB budget (combined 192M, postgres 112M, and so on) is written in the compose comments. It probably runs on 768 MB too. On a 1 GB box, I have even run it as a bot-only server with no UI (watch-mjw.f3liz.casa, which now lives on the same box as sukhi.f3liz.casa).
"It runs even on a small server" is not a slogan for me. It is true because I split it so that it folds. That is the order.
By the way, in this small-box story, the first thing that fell over was not Elixir. It was another runtime, Bun. Now Elixir does the ActivityPub translation itself, and Bun is retired from production. That whole story is in a separate post on this blog: We used fedify, then graduated from it.
Scaling out
In the opposite direction from running small, scaling out is also the same code. The direction of scaling has two.
Delivery throughput is handled by Postgres and NATS, not by distributed Erlang. Postgres is the system of record, and NATS JetStream is the event plane. The gateway only writes outbox rows, and delivery only reads them and delivers. Because the roles are split across that one seam, adding delivery capacity does not need any clustering work. Outbox.Relay, the singleton GenServer at the center of delivery, has said "for future horizontal scale" from the very start.
The feature surface is handled by distributed Erlang. The Mastodon REST API is a plugin catalogue on its own node, reached by :rpc. One file is one capability, registered automatically at boot (SukhiApi.Registry) and grouped into addons. So to add an endpoint, I just drop a file, and I do not touch the router. plugin_nodes is a list, and the gateway uses the first node it can reach. So I can run several plugin nodes, and scale only the feature layer on its own nodes, without touching gateway or delivery.
So there are two planes. Postgres and NATS carry delivery, distributed Erlang carries features. I start from the folded state, and I carve out only the parts that need their own node. Without redoing the design.
It actually runs
The most honest thing I want to say in this introduction is here. This is not a plan. It is something that is running.
The ones I have actually federated with, and checked the round-trip, are Mastodon, Misskey, and hackers.pub. The ActivityPub translation is native Elixir (SukhiFedi.Fedi), and an activity that goes through it reaches the other side and comes back. For other servers' quirks, like the signature schemes (draft-cavage and RFC 9421), how quotes are written, and emoji reactions, I have already put in interop code. But I have not tested those against a live peer yet. On the Mastodon-compatible API, clients like Elk, Phanpy, Ivory, and Tusky have enough small fixes that they no longer error after login.
You can also see it running, not only read about it. sukhi's /map is a public page that draws the structure I have described, from the gateway, through the NATS switchyard, to delivery, and out into the fediverse, as a live rail map. The number of trains on it is not decoration. It is how much really flowed that day. Stations and rails are a metaphor, but the way they connect is one-to-one with the real wiring. How it was built is written in a separate post on this blog: I put trains inside my server.
Some things are still missing. Full-text search, the streaming WebSocket, a native Misskey client API. They are lined up in TODO.md and OPEN_QUESTIONS.md. These are gaps at the edges, not in the core. So please look forward to what comes next.
How it got here
My first commit was 16 March 2026, an "inital commit" with the typo still in it. From there, over four months, I piled up 461 commits, and it became what it is now.
In April, I split delivery out of the main body, and the skeleton of federation appeared. May was a month only of fleshing it out. Quotes, emoji reactions, MFM, DMs, and the paths for talking to remote servers grew all at once. It was not an easy month. Near the end of it, there was even a day when my signed POSTs kept getting bounced by hackers.pub with 401. My commit log from that day just goes on and on, logging, re-signing, and self-verifying, and the last line is "sign only the mandatory header set, like Mastodon". On the nights when federation does not go through, I learned that the cause is usually on my side.
The biggest rebuild is gathered on 12 June. I moved the ActivityPub translation that Bun was doing into Elixir, I carved out the combined release for small boxes, I threaded the Ed25519 keys from end to end, and on that same day I marked the Bun sidecar as "retired". Because I had split it so that it can be made small, this was not a redesign, but only a change in how it boots.
After that too, there was a day I stopped two irreversible data losses just in time, and I built /map only last week. Nothing dramatic, but this is how I have piled it up, one day at a time.
Try it
git clone https://github.com/sukhi-social/sukhi-fediIf you follow the Quick start in the README, you can build the whole stack on your machine and run it. It comes up at localhost:4000. For a proper self-host, see SETUP.md. If you want to know the inside, read docs/ARCHITECTURE.md. Both of them are written so that you can rebuild the whole thing from that one file. It is AGPL-3.0, so you can take it and make it your own.
That is sukhi-fedi. A federated server that folds small and scales out. If it seems useful to you, please take a look.
(sukhī is Pali for "happy". It is a name with a small wish, that a place where people meet can be woven, and kept, with ease.)
Comments
This post is on the fediverse — reply to it from your account and it appears here.
No comments yet.