Open Free and open source — read the code

Running your own relay

A relay is one container and one database. It needs no account service, no federation, and no connection to us — a relay serving one household is the default configuration, not a stripped-down one.

⚠️ Flamenet Files is optional. If you host only Messenger, nothing is missing: your relay keeps its own accounts and everything works. Files matters only if you want one credential across both products — see Three shapes below.


The shortest thing that works

mkdir -p /srv/flamenet && cd /srv/flamenet

# The issuer keypair. This mints your users' session tokens; losing it signs
# everybody out, and leaking it lets somebody mint a token for any account.
docker run --rm flamenet-relay:latest generate-issuer-key > issuer.pem

cat > .env <<'ENV'
FN_RELAY_AUDIENCE=relay.example.com
FN_ISSUER_PRIVATE_KEY=<the seed from issuer.pem>
FN_ISSUER_PUBLIC_KEY=<the public key from issuer.pem>
FN_POSTGRES_URL=postgres://flamenet:...@db/flamenet
ENV

docker compose up -d

Then check it is what you think it is:

curl https://relay.example.com/.well-known/flamenet/relay

⚠️ FN_RELAY_AUDIENCE must be the hostname clients dial. Tokens carry it and the relay checks it, so a mismatch rejects every token with no other symptom. A relay reachable at several names still has exactly one of these.

⚠️ Terminate TLS in front of it and forward the client address. Clients hold key material, and plain HTTP hands it to anyone on the path. Sealed submissions are metered per recipient and per IP; behind a proxy that does not set X-Forwarded-For, every request looks like the proxy and that limit is theatre.

Every variable is in Configuration.


Voice and video calls

Calls need a TURN server. docker-compose.prod.yml runs one — a coturn service alongside the relay — so deploying with that file gets you working calls rather than calls that work for some people.

🔴 This is not the optional extra it sounds like. STUN alone connects two peers only when at least one NAT is permissive. Symmetric NAT and CGNAT — most mobile networks — need a relay in the middle, so without TURN a large share of real calls fail while messaging keeps working perfectly. That is a miserable thing to diagnose from a bug report, and until 2026-08-29 it was the default: TURN lived only in the federated compose file, and anyone following this page got a relay whose /e2e/turn answered 503.

Three variables, all required by that file, all in .env.example:

FN_TURN_SECRET=$(openssl rand -hex 32)   # shared with coturn; must match exactly
FN_TURN_HOST=turn.example.com            # what clients dial; also coturn's realm
FN_TURN_EXTERNAL_IP=203.0.113.10         # the host's PUBLIC address

⚠️ Open 3478/udp, 3478/tcp and 49160-49200/udp on the host firewall. A closed relay range fails exactly like a wrong secret: the call connects and then carries no audio.

⚠️ Keep the TURN record unproxied in DNS. Cloudflare does not proxy UDP, so a proxied record makes every relayed call fail while the record looks perfectly healthy.

⚠️ FN_TURN_EXTERNAL_IP must be the address the outside world sees, not the one on the interface. Behind NAT, coturn that does not know its public address hands out candidates nobody can reach, and it presents as packet loss.

To run without calls, remove the coturn service from the compose file. Leaving the variables blank is not the way to choose that — the stack refuses to start, on purpose, so that "calls are broken" cannot be the thing you find out later.


Three shapes

Accounts live Who runs it Files needed
Managed Shared FlameNet service Us no
Self-hosted, shared identity Shared FlameNet service You no
Self-hosted, local (default) Your relay's database You no

The third is the default and is not a lesser mode. Users register, exchange keys, message each other, get a portable address and verify the transparency log exactly as anywhere else. What the shared account service adds is one credential across Flamenet Messenger and Flamenet Files — nothing else.

To adopt the shared service, set FN_ENCLAVE_PUBLIC_KEY to the account service's Ed25519 key. Your relay then advertises accounts: shared in its descriptor, and clients pick that up automatically.

⚠️ Clients accept a named account service only if they already trust it. A relay claiming a service the client does not know is treated as local. That is deliberate: without it, an operator could point their users' passphrase-derived secrets at a host they control.

⚠️ Set the public key and HMAC tokens are refused. A fallback would let anyone who ever learned the retired shared secret keep minting sessions forever. Migrate the account service first, then the relays.


Federation, if you want it

Off by default, and the default is load-bearing.

FN_FEDERATION=allowlist
FN_FEDERATION_PEERS=relay.friend.example
FN_FEDERATION_KEY=<base64 Ed25519 seed>

⚠️ A closed relay opens no outbound connections at all. No telemetry, no update check, no directory to register with. The only outbound code is the SMTP client, itself disabled unless configured.

⚠️ Federation costs metadata. Each relay in an exchange learns the other had traffic for it, so a slice of your users' social graph is spread across operators you do not run. If your relay exists so one group can talk without anyone else involved, closed is the correct answer.

FN_FEDERATION_KEY is deliberately not the issuer key: a key that only says "this relay said this" should not also mint tokens for your users.


Verifying what you are running

A relay you cannot check is a relay you are trusting, which is the thing this project exists to avoid.

# What it claims to be
curl https://relay.example.com/.well-known/flamenet/relay

# The signed transparency-log head
curl https://relay.example.com/e2e/log/head

# An inclusion proof, checked against a head fetched separately —
# never against the root the proof response carries
curl "https://relay.example.com/e2e/log/inclusion?index=0&size=<size>"

⚠️ Check a proof against a head you fetched separately. A server that supplies both halves of a comparison has proven nothing.


What a relay operator can and cannot see

Cannot: message contents, attachment contents, group message contents. Those are end-to-end encrypted and the keys never reach the relay.

Can: who has an account, which devices they have, that an envelope went from one device to another and when, and how large it was. Retention windows for all of it are in Configuration, and the relay purges on its own timer with nothing external required.

🔴 This relay holds no key to any message. It used to: FN_AT_REST_SECRET sealed bodies on the plaintext path, which defended against a stolen database dump and not against the operator. That path was removed on 2026-09-04 along with the key, so there is no longer an "it does not defend against you" caveat to pass on to your users. If the variable is still in your .env, nothing reads it — delete it.

⚠️ Never raise FN_LOG_LEVEL to debug on a machine with real accounts. It logs request URLs, and a protocol URL contains a device id.