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.