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://git.extant2000.com/extant2000/flamenet-relay
cd flamenet-relay
cp .env.example .env
Generate the three secrets .env asks for:
openssl rand -hex 32 # POSTGRES_PASSWORD
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.
Set FN_CONTACT_EMAIL to a mailbox you read, so the
contact and safety pages name you rather than nobody, and start:
docker compose up -d
Every variable in .env reaches the relay. The compose
file passes the whole file through, so a setting you add there is a
setting the relay reads, and nothing is quietly dropped for not being on
a list.
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.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=... \
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 | |
|---|---|
| Collected envelopes, on Postgres | when the app's next poll confirms them |
| Collected envelopes the app never confirms, or any on SQLite | 1 day |
| Undelivered envelopes | 30 days |
| Attachments | 30 days |
| Used one-time prekeys | 7 days |
Override any of them with FN_DELIVERED_RETENTION_DAYS,
FN_UNDELIVERED_RETENTION_DAYS and
FN_ATTACHMENT_RETENTION_DAYS. Deleting on confirmation is
FN_DELETE_ON_ACK, on by default; 0 turns it
off. It never runs on SQLite, which reuses row ids, so the cursor there
would delete the next message unread.
None of this reaches your backups. A database dump or a WAL archive keeps what it captured for as long as you keep it, so your backup retention is part of your message retention.
There is no unencrypted message store any more, so
FN_MESSAGE_RETENTION_DAYS and
FN_READ_MESSAGE_RETENTION_DAYS are not read. Setting them
does nothing; see docs/PLAINTEXT.md in the relay
repository.
The compose files, and what each one runs
Five files, layered. docker-compose.yml is the base and
the others are overlays, so
-f docker-compose.yml -f <overlay> is the shape.
| File | Services | For |
|---|---|---|
docker-compose.yml |
db, relay |
The base. Publishes to 127.0.0.1; what the quick start
uses. |
docker-compose.build.yml |
relay |
Builds the image from source instead of pulling it. Needed on ARM. |
docker-compose.prod.yml |
db, redis, relay,
coturn, obfs4bridge |
A public relay: rate limiting that survives a restart, TURN for calls, and a bridge for networks that block the relay. |
docker-compose.federated.yml |
db, redis, relay,
caddy, coturn |
The same, with Caddy terminating TLS for you. |
docker-compose.pooled.yml |
pgbouncer, relay |
Connection pooling, for when one Postgres is serving several relays.
An overlay on prod, not on the base: it uses the
internal network that file defines. |
redis is optional and the relay says so at
boot. Without it, rate limits are counted in process: correct
for one relay, and reset by a restart. With it they are shared and
durable. Nothing else depends on it.
obfs4bridge is for reachability, not
privacy. It gives the relay a second address that does not look
like a relay, for networks that block the first. It publishes its own
port because it carries no SNI for a reverse proxy to route on. Clients
paste the bridge line into Settings; see FN_OBFS4_* in
docs/CONFIGURATION.md in the relay repository.
pgbouncer is only worth it above one
relay. A single relay opens few enough connections that
Postgres handles them directly. Layer it on prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
-f docker-compose.pooled.yml up -d
Voice calls
Optional, and only needed for calls.
docker-compose.yml, the file the steps above use, ships
no coturn, on purpose: it publishes to
127.0.0.1 and is meant for working on the relay, where two
clients on one machine reach each other directly. /e2e/turn
answering 503 there is correct rather than a missing piece.
Calls across symmetric NAT and CGNAT need a relay in the middle, and
that lives in docker-compose.prod.yml, behind a profile so
nothing about calls runs, or has to be configured, unless you ask for
it:
docker compose -f docker-compose.yml -f docker-compose.prod.yml --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.
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.
One step by hand for older SQLite relays
This is for a relay on SQLite (no FN_POSTGRES_URL) that
was already running before 25 September 2026. Postgres relays can skip
it, and so can a SQLite relay first started after that date.
When email verification was added, the upgrade was meant to mark
every account that already existed as verified, so that switching
verification on later would lock nobody out. On SQLite that step failed
without an error and marked nobody. Nothing changes until you set
FN_EMAIL_VERIFICATION_REQUIRED. Once you do, everyone who
signed up before that upgrade is turned away and asked for a code from
an email that was never sent.
The relay now does this correctly, but each upgrade step runs once,
and a relay that already ran the broken one will not run it again. Stop
the relay, copy relay.sqlite somewhere safe (it is wherever
FN_DB points, if you set it), open it with
sqlite3 relay.sqlite, and run:
UPDATE users SET email_verified_at = CURRENT_TIMESTAMP
WHERE email_verified_at IS NULL
AND (created_at IS NULL OR created_at <= (
SELECT created_at FROM _fluent_migrations
WHERE name = 'FlamenetRelay.AddEmailVerification'));
It marks only the accounts that existed before verification arrived. Anyone who signed up since still has to confirm their address. Running it a second time, or on a relay that never needed it, changes nothing.
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 closed rather than nominally closed. The relay opens an outbound connection only for a feature you have switched on. Each one, and the setting that turns it on:
- Password-reset mail, over SMTP to
FN_SMTP_HOST. - Error reports, to the collector named in
FN_SENTRY_DSN. Off unless set. It sends only to that address, and only scrubbed errors and 5xx responses other than 503. - iOS wake-ups, to Apple's push service, when
FN_APNS_KEY_P8,FN_APNS_KEY_IDandFN_APNS_TEAM_IDare all set. - Federation, to peer relays, when
FN_FEDERATIONisallowlistoropen. Sending to a peer also needsFN_FEDERATION_KEY. - Donations, to
api.stripe.com, whenFN_DONATE_SECRETis set. - Attachments, to an S3-compatible store, when
FN_S3_ENDPOINTis set.
It also connects to the databases you point it at: Postgres at
FN_POSTGRES_URL and Redis at FN_REDIS_URL.
Nothing reports to this project: there is no telemetry, no update check
and no directory to register with. With every setting above unset, the
relay keeps its data in a local SQLite file and opens no outbound
connections.
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.
If you would rather not run a server
Hitt Hosting installs this
relay from its app catalogue. You pick a name, set the same handful of
options the quick start writes into .env, and it brings up
the relay on a subdomain of its own, with a certificate, a Postgres
database and an issuer keypair generated for that install and no other.
Five dollars a month, or fifty a year, with a week to try it first.
It runs the published image, pinned to a commit rather than
:latest, with a redis alongside it for held polls. What
changes is who holds the box. Hitt Hosting can read whatever your relay
can read, which is recipients, timing and approximate sizes, and it can
be asked for them by somebody else. Sealed envelopes stay sealed, so
nothing here weakens the encryption, but it puts an operator back in
your threat model, and removing the operator is what the section above
says self-hosting is for.
Two things differ from a relay you run yourself. Remote erase is off, and because the image is pinned, it updates when Hitt Hosting moves the pin rather than when you pull.