Self-hosting
Running your own relay is the point of this project, not a footnote to it. There is no hosted service to sign up for, no account with us, and nothing that phones home. Two containers and a config file.
The repositories
Everything this page describes is published under the MIT licence.
| Repo | What it is |
|---|---|
| flamenet-relay | The server you are about to run |
| flamenet-e2e | The protocol: JavaScript engine, specification, published test vectors |
| flamenet-messenger-ios | The iOS app, and the source of truth for all of the above |
| flamenet-messenger-android | The Android app, with its own Kotlin engine |
You need the first one to run a relay. The rest are there so that "self-hostable" is something you can check rather than something you are told.
Quick start
git clone https://gitlab.com/paulhitt/flamenet-relay
cd flamenet-relay
cp .env.example .env
Generate the three secrets .env asks for:
openssl rand -hex 32 # POSTGRES_PASSWORD
openssl rand -base64 32 # FN_AT_REST_SECRET
openssl genpkey -algorithm ed25519 -out issuer.pem
openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64 # FN_ISSUER_PRIVATE_KEY
openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64 # FN_ISSUER_PUBLIC_KEY
🔴 -hex for the database password, not
-base64, and this one is not cosmetic. It is
interpolated into
postgres://flamenet:<password>@db:5432/flamenet,
where base64's /, + and = are all
meaningful. A password containing / makes that URL invalid
and the relay dies at start-up with a bare Code=-1000 and
the connection string echoed back at you — which reads like the database
is unreachable rather than like the password needs escaping.
openssl rand -base64 32 produces one of those roughly
three times in four, so following the old instructions
usually failed.
Then create the proxy network the compose file attaches to, and start:
docker network create web
docker compose up -d
⚠️ docker network create web is not optional and
used to be missing from these instructions. The compose file
declares that network as external — it is where a reverse proxy would
find the relay — so without it up -d stops with "network
web declared as external, but could not be found", after building the
image. Nothing else is needed if you are not running a proxy; an empty
network costs nothing.
That pulls a prebuilt image, so there is no Swift toolchain to install and nothing to compile. The images are amd64 only — they are built by this project's CI, which runs on an x86_64 machine. On a Raspberry Pi or an ARM VPS, build from source instead:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
That is a supported path rather than a fallback, and it is also the one to use if you would rather run what you can read than an image somebody else built. docs/REPRODUCIBLE.md says how far that checking currently goes, including where it stops.
Open http://localhost:8080. The relay serves its own landing page, so a working install explains itself instead of answering with a 404.
What those secrets are
FN_ISSUER_PRIVATE_KEY/FN_ISSUER_PUBLIC_KEY— an Ed25519 keypair. The relay issues short-lived tokens signed with the private half and verifies them with the public half, which is configuration and is never fetched over the network. Keepissuer.pem: losing it invalidates every token you have issued, and leaking it lets anyone mint tokens your relay accepts.FN_AT_REST_SECRET— encrypts stored message bodies for conversations where encryption is turned off. It defends against a stolen database dump on its own. It does not defend against you: you hold the key.POSTGRES_PASSWORD— the database. Not published on the host by the compose file; the relay reaches it over the compose network.
Storage
Postgres by default in the compose file. The relay also runs on SQLite, which is what a single binary with no second process uses:
FN_ISSUER_PUBLIC_KEY=... FN_ISSUER_PRIVATE_KEY=... FN_AT_REST_SECRET=... \
flamenet-relay serve --hostname 0.0.0.0 --port 8080
Set FN_POSTGRES_URL and it uses Postgres; leave it unset
and it writes relay.sqlite. The schema is identical either
way.
Attachments are files rather than rows, so they live on a volume in
both modes (FN_ATTACHMENTS_DIR,
/data/attachments in the container).
Putting it behind TLS
The compose file binds to 127.0.0.1:8080 on purpose.
Terminate TLS in front of it with whatever you already run — Caddy,
nginx, Traefik — and point it at that port. Two things matter:
- HTTPS is not optional in practice. Clients hold key material; serving them over plain HTTP hands it to anyone on the path.
- Forward the client address. Sealed submissions are
metered per recipient and per IP, and behind a proxy that does not set
X-Forwarded-Forevery request looks like it came from the proxy.
Retention
The relay purges on a timer of its own, inside the process. Nothing external has to be scheduled, and there is no cron to wedge. The defaults:
| Deleted after | |
|---|---|
| Delivered envelopes | 1 day |
| Undelivered envelopes | 30 days |
| Attachments | 30 days |
| Used one-time prekeys | 7 days |
| Unencrypted messages, once read | 30 days |
| Unencrypted messages, regardless | 90 days |
Override any of them with FN_DELIVERED_RETENTION_DAYS,
FN_UNDELIVERED_RETENTION_DAYS,
FN_ATTACHMENT_RETENTION_DAYS,
FN_READ_MESSAGE_RETENTION_DAYS,
FN_MESSAGE_RETENTION_DAYS.
Voice calls
Optional, and only needed for calls. The compose file ships a coturn service behind a profile, so nothing about calls runs — or has to be configured — unless you ask for it:
docker compose --profile calls up -d
Set FN_TURN_HOST, FN_TURN_SECRET and
FN_TURN_EXTERNAL_IP in .env before you do;
coturn refuses to start without them. The relay mints credentials from
the same secret and never proxies media. Messaging works without any of
it, and with the profile off /e2e/turn answers 503, which
is a supported deployment rather than a fault.
⚠️ These three used to be required by the compose file
whether or not you wanted calls — and because
.env.example ships them empty, and Compose treats an empty
value as missing, copying the example and running
docker compose up -d failed on its first command.
Keeping it up to date
Running the published image: docker compose pull then
docker compose up -d.
Building from source: git pull then
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build.
🔴 The --build matters when you are building from
source. Compose reuses an existing image tag rather than rebuilding it,
so without it a git pull brings down new source and leaves
you running the old binary — no error, no warning, no update. Check what
you are actually running with
docker compose images relay.
Migrations run at boot, so there is no separate step. They are
additive: a newer relay reads an older database and brings it forward.
Take a dump first anyway —
docker compose exec db pg_dump -U flamenet flamenet —
because the reverse is not true, and an older relay against a newer
database is not a supported direction.
Telling your users about updates
The Android app asks your relay whether a newer build exists — not an address of ours, because an app that says it does not phone home should not be checking in with us from every device. The side effect is that a relay which publishes nothing produces no update prompts at all.
Set four variables in .env and your relay publishes a
release manifest that the app reads:
FN_ANDROID_VERSION_CODE=4
FN_ANDROID_VERSION_NAME=0.4
FN_ANDROID_SHA256=<sha256 of your APK> # optional
FN_ANDROID_NOTES=What changed in this build. # optional
Serve your APK at /downloads/flamenet-messenger.apk on
the same host. The app builds the download URL from the relay's own
origin and ignores anything the manifest says about where the file lives
— so your relay can point your users at builds on itself, and at nothing
else. Leave the variables unset and the route simply is not there, which
is the right default: a relay should not invent a release its operator
never chose.
Verifying what you are running
scripts/verify-build.sh # digests of the engine sources
scripts/verify-build.sh --rebuild # prove the vendored ML-KEM rebuilds byte-identically
Standing alone, or joining the federation
A relay you run is self-contained by default, and staying
that way requires no configuration at all.
FN_FEDERATION defaults to closed. Nothing is
disabled by that choice: your users register, exchange keys, message
each other, and verify your transparency log exactly as they would
anywhere else. A closed relay is a complete product, not a crippled
one.
It is also genuinely closed rather than nominally closed. The relay
has one piece of outbound network code in it — the SMTP client for
password reset mail — and that is disabled unless you configure a mail
server. With FN_SMTP_HOST unset and federation closed, the
process makes no outbound connections at all. It talks
to its database and to whoever dials it, and to nothing else. There is
no telemetry, no update check, and no directory to register with.
FN_FEDERATION |
What it means |
|---|---|
closed (default) |
Your relay talks to your users and to nobody else. |
allowlist |
Your relay exchanges traffic only with peers you name. |
open |
Your relay accepts peers it has not met. |
Your relay advertises which of these it is at
/.well-known/flamenet/relay, so a peer reads your terms
before attempting anything rather than discovering them from a rejected
request.
⚠️ Federation is not free, and the cost is metadata. Sealed sender hides who is writing from the relay carrying the message; it does not hide your relay's existence, or which relays it exchanges traffic with, from the operators on the other end. Joining a federation distributes a slice of your users' social graph across servers you do not run. That is a real reduction in what your users get from you hosting them, and it is the honest reason this defaults to off.
Choose it deliberately. If your relay exists so that one household,
one company or one group of friends can talk without anyone else
involved, closed is not the cautious option — it is the
correct one.
Identities work either way
An account key and the fnid derived from it are worth
having on a closed relay. They are what lets someone move: the address
comes from a key your user's client derives from their passphrase, not
from a row number you assigned, so the same person can register the same
identity somewhere else and be recognised as themselves. That holds
whether or not you ever federate — including if you shut your relay
down, which is precisely when it matters.
What self-hosting actually buys you
It removes the operator from your threat model, because you are the operator. Nobody else holds the at-rest key, nobody else can be asked for your data, and nobody else decides when to retain it.
It does not make you anonymous to the people you talk to, and it does not change what any relay necessarily sees: recipients, timing, and approximate sizes. It also does not solve key distribution — your relay hands out prekey bundles, and safety numbers remain the check that does not require trusting it.