Configuring a relay
Every variable the relay reads, what it breaks if it is wrong, and which of the three deployment shapes needs it.
Read from Sources/FlamenetRelay/Config.swift and
Entrypoint.swift rather than from memory. If a variable is
not in this file it is not read.
The three shapes
A relay is one of these. Nothing needs choosing beyond the first column — the default is a complete, self-contained relay.
| Accounts live | Federates | Who runs it | |
|---|---|---|---|
| Managed | The shared FlameNet account service | Operator's choice | Us |
| Self-hosted, shared identity | The shared FlameNet account service | Operator's choice | You |
| Self-hosted, local (default) | This relay's own database | Operator's choice | You |
A local relay is not a lesser one. 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 more.
Required
Without these the relay refuses to start. That is deliberate: a relay that starts with a missing security parameter is worse than one that does not start, because nobody finds out until it matters.
| Variable | Notes |
|---|---|
FN_RELAY_AUDIENCE |
The hostname this relay is addressed by, e.g.
relay.example.com. 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. |
FN_ISSUER_PUBLIC_KEY |
Base64 Ed25519 public key of whatever mints this relay's tokens. Never fetched over the network. |
Identity — minting your own tokens
| Variable | Default | Notes |
|---|---|---|
FN_ISSUER_PRIVATE_KEY |
unset | Base64 Ed25519 seed. Set it and this relay issues
its own tokens and serves /account/*. Unset and those
routes do not exist at all, rather than existing and refusing. |
FN_TOKEN_LIFETIME |
300 |
Seconds. Short because there is no revocation list — expiry is the only thing that stops a leaked token. |
FN_CLOCK_SKEW |
60 |
Seconds of tolerance on a proof timestamp. |
⚠️ In one process, a relay compromise is an issuer compromise: the signing key is in the same address space. That trade is for self-hosters, who are running one box either way.
Storage
| Variable | Default | Notes |
|---|---|---|
FN_POSTGRES_URL |
unset | Set it and Postgres is used. |
FN_DB |
relay.sqlite |
SQLite file, used when the above is unset. The schema is identical on both — SQLite is not a degraded mode, and is the right choice for a relay serving a household or a company. |
FN_ATTACHMENTS_DIR |
attachments |
Blobs are files, not rows, in both modes. |
Network
| Variable | Default | Notes |
|---|---|---|
FN_HOST |
127.0.0.1 |
Not reachable from outside the host as shipped. Terminate TLS in front of it. |
FN_PORT |
8080 |
|
FN_LOG_LEVEL |
error |
⚠️ Never raise to debug on a machine with real
accounts: it logs request URLs, and a protocol URL contains a device
id. |
⚠️ HTTPS is not optional in practice. Clients hold
key material, and serving them over plain HTTP hands it to anyone on the
path. Forward the client address too — sealed submissions are metered
per recipient and per IP, and behind a proxy that does not set
X-Forwarded-For every request looks like the proxy.
Federation
Off by default, and the default is load-bearing: a relay run for one household should stand alone without configuring anything.
| Variable | Default | Notes |
|---|---|---|
FN_FEDERATION |
closed |
closed, allowlist, or open.
An unrecognised value falls back to closed rather than
refusing to boot — a typo in an optional variable should not stop a
relay serving its own users. |
FN_FEDERATION_PEERS |
empty | Comma-separated hostnames, used only under allowlist.
Enforced outbound as well as inbound, from the same
rule. |
FN_FEDERATION_KEY |
unset | Base64 Ed25519 seed. Required when
FN_FEDERATION is not closed — the relay
refuses to boot otherwise, rather than starting unreachable and letting
a stranger discover it. |
FN_FEDERATION_KEY is deliberately not
the issuer key. A key that only ever needs to say "this relay said this"
should not also be able to mint a token for any user on it.
⚠️ 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. That is the
honest reason this is off by default. If your relay exists so one group
can talk without anyone else involved, closed is the
correct answer, not the cautious one.
✅ A closed relay opens no outbound connections at all. The only outbound code in the relay is the SMTP client below, itself disabled unless configured. No telemetry, no update check, no directory to register with.
Shared FlameNet accounts
Only for a relay that accepts identities from a FlameNet account service. Leaving all of these unset gives you a relay with its own user table.
| Variable | Default | Notes |
|---|---|---|
FN_ENCLAVE_PUBLIC_KEY |
unset | Base64 Ed25519 public key from the account service's
/.well-known/jwks.json (the x member,
base64url-decoded). This is the one to use. |
FN_ENCLAVE_JWT_SECRET |
unset | The older shared HMAC secret. Works, and is why third-party relays were never possible: the secret that checks a token also mints one, so anything holding it can impersonate any account. |
FN_ACCOUNTS_URL |
https://accounts.flamenet.io |
Which account service to advertise. Point it at your own if you run one. |
⚠️ Configured, not fetched. This decides who may assert an identity at your relay; a relay that learned it over the network could be pointed at a different issuer by whoever controls DNS. Copy it in once.
⚠️ Set the public key and HMAC tokens are refused. That is intentional — a fallback would let anyone who ever learned the retired secret keep minting sessions forever. Migrate the account service first, then the relays.
Limits
Defaults are sane; raise them only with a reason.
| Variable | Default | Guards |
|---|---|---|
FN_BUNDLE_RATE |
120/hr |
Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
FN_BUNDLE_RATE_TARGET |
6/hr |
Per requester-target pair. Deliberately not metered per target alone — that would let one abuser make a popular account unstartable for everyone. |
FN_SIGNUP_MAX_PER_HOUR |
50/hr |
New accounts per IP. ⚠️ An IP is not a person — carrier CGNAT,
offices and campuses all share one. It was 5, which refused
the sixth user behind any shared connection. Per-IP is trivially
rotated, so treat this as a speed bump against lazy bulk registration
rather than a defence. |
FN_SIGNUP_MAX_PER_NETWORK |
400/hr |
New accounts per network — a /24 for IPv4, a /48 for IPv6. The
per-address limit counts a rented subnet as separate strangers, and more
addresses is the cheapest thing an abuser buys. Deliberately generous: a
campus or a carrier behind CGNAT is thousands of real people on one
network, and refusing them is the worse failure. 0
switches network counting off, leaving only the per-address
limit. |
FN_SIGNUP_POW_BITS |
0 (off) |
Leading zero bits of SHA-256 work a new account must do before it is
created (relay#52). ⚠️ Not a CAPTCHA on purpose — a
CAPTCHA hands a third party every visitor's connection, which is a
strange thing for a product whose argument is that it collects almost
nothing. This collects nothing at all. Capped at 24 however
it is set: higher does not read as spam protection to the person it
hits, it reads as an app that hangs on the sign-up button. |
FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW |
5/hr |
What a client too old to answer the challenge gets instead, per
address. 🔴 This is what makes FN_SIGNUP_POW_BITS
switchable on at all. Refusing an unanswered sign-up outright
would break every build already in somebody's hand, and break it
invisibly — the APK in the field is Android 0.6 (2026-08-31) and the
client-side solver landed 2026-09-01, one day later. An old build now
drops to this strict allowance rather than hitting a wall, so the
opt-out costs something without stranding anyone. ⚠️ Per-address is
trivially rotated, so this bounds a lazy attacker, not a determined one;
the gain is that the strong thing can be enabled at all.
0 restores the wall — the right setting
once both shipped clients solve challenges. |
| FN_VOUCH_REQUIRED | false | Require an
invite from an existing member to sign up (relay#43). 🔴 Inert
on its own — without FN_VOUCH_UNTIL the gate never
engages, deliberately: an invite-only server nobody can join is worse
than an open one. | | FN_VOUCH_UNTIL | unset | Unix seconds
after which vouching stops being required. 🔴 The gate turns
itself off. Vouching makes bulk sign-ups costly and gives every
account a provenance; it also slows genuine growth and creates an
in-group. That is a reasonable trade for a phase and a bad one for a
policy, so it cannot be switched on without saying when it stops. | |
FN_SEND_RATE | 600/hr | Envelopes, per sender
or per recipient when sealed. | | FN_ATTACHMENT_RATE |
60/hr | | | FN_ATTACHMENT_BYTES_RATE |
200 MiB/hr | | | FN_ATTACHMENT_MAX_BYTES |
10 MiB | Per blob. |
Erasing a lost device
Off. The relay does not carry instructions for a device to erase itself.
| Variable | Default | What it does |
|---|---|---|
FN_DEVICE_WIPE |
false |
When on, the relay accepts a signed erase instruction for one device
and hands it over when that device next asks. Accepts true,
1 or yes. |
Off because the feature is not finished end to end, not because the route is dangerous. No client in the field can honour a wipe — the Android build released on 2026-08-31 (v8) never asks for one — and the issuing side still needs the passphrase re-entered before an order can be signed. A half-built remote erase that is on by default looks like a guarantee and is not one yet.
The relay is only ever a courier. The instruction is signed by the
account key, which the relay does not hold, so it cannot forge one; and
withholding one stops a wipe rather than causing one. When off, both
routes refuse with wipe_not_enabled rather than a 404, so a
client can tell a server that has this switched off from one too old to
know what it is.
Reports about content
On. A person can report something to whoever runs the server, so blocking is not the only thing they can do.
| Variable | Default | What it does |
|---|---|---|
FN_CONTENT_REPORTS |
on | Set to false, 0, no or
off to stop taking reports. Anything else, including a
typo, leaves them on. |
Off is a legitimate answer, not a broken relay. If nobody is going to read them, collecting reports is a promise the server cannot keep, and saying so plainly is better than a queue nobody opens.
When off, two things happen so that nothing pretends otherwise:
/.well-known/flamenet/relay reports
"reports": false, which lets a client leave the Report
option out of the interface entirely; and the route itself refuses with
reports_not_accepted rather than a 404, so a client can
tell "this server does not take reports" apart from "this server is too
old to know what a report is".
There is deliberately no route that reads reports. They are read out of band by whoever operates the server — an endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask.
Proving an address at sign-up
Off. An account can use the relay the moment it is created.
| Variable | Default | What it does |
|---|---|---|
FN_EMAIL_VERIFICATION_REQUIRED |
false |
When on, an account with an email address must spend a code mailed
to it before the relay will issue that account a token. Accepts
true, 1 or yes; anything else,
including a typo, leaves it off. |
Two things have to be true before anybody is asked to prove anything: the switch above, and SMTP being configured. A relay that cannot send mail can never answer the question it is asking, so it does not ask — the same rule the key-log enforcement follows.
Before you turn this on, check what your users are running. The gate sits on the token route, which is the door to everything: an account that cannot mint a token cannot poll, register a device, or fetch a prekey bundle. A client with no screen for entering the code has no way through that door and no way to tell the person why. The Android build released on 2026-08-31 (v8) is one of these. Turning this on for a relay it talks to would let somebody create an account and then strand them.
Two things this deliberately does not do:
- It never touches an account that already exists. The migration marks every account present at upgrade as verified. Switching this on does not lock out your current users.
- It never gates an account with no email of its own — those authenticated by the shared service have nothing to prove.
Codes last 24 hours and one is live at a time; asking for another replaces the first. If the mail fails to send, the code is deleted rather than left in the database as a credential nobody received.
Retention
The relay purges on its own timer, in-process. Nothing external is needed.
| Variable | Default | Notes |
|---|---|---|
FN_DELIVERED_RETENTION_DAYS |
1 |
|
FN_UNDELIVERED_RETENTION_DAYS |
30 |
⚠️ Must stay below the client's retired-signed-prekey retention of 45 days. |
FN_ATTACHMENT_RETENTION_DAYS |
30 |
Mail, calls, donations
All optional. Each is absent rather than half-enabled when unconfigured.
| Variable | Notes |
|---|---|
FN_SMTP_HOST, FN_SMTP_PORT,
FN_SMTP_FROM, FN_SMTP_TLS,
FN_SMTP_USERNAME |
Password-reset mail. Unset disables reset entirely — a flow that mints a token it cannot deliver leaves the account locked out and a live token in the database. |
FN_TURN_HOST, FN_TURN_SECRET,
FN_TURN_TTL |
Voice calls. |
FN_DONATE_PROVIDER, FN_DONATE_SECRET |
Off unless set. A relay somebody else runs has no business soliciting money for us. |
FN_DONATE_PUBLISHABLE_KEY |
Required alongside the two above: the payment fields are Stripe's own, mounted on the page, and they cannot mount without it. Set the secret and not this and the donate page shows no form at all. |
FN_DONATE_ACCOUNT |
The connected account to charge, if FN_DONATE_SECRET
belongs to a Stripe platform rather than to the account meant
to receive the money. Sent as Stripe-Account, which makes
the payment a direct charge there. Blank charges whatever account the
key itself belongs to — the right answer when you are self-hosting with
your own key, and the wrong one with a platform key, where Stripe
answers 200 and the money lands on the platform with nothing on the page
looking wrong. With this set, the publishable key stays the
platform's. |
Watching the key log (relay#32)
| Variable | Default | What it does |
|---|---|---|
FN_KEY_LOG_SUBJECT_QUERIES |
on | Set to false, 0, no or
off to stop listing a single account's entries. The log
head and the inclusion proofs still work — only per-subject enumeration
goes away, and the route then refuses with
subject_queries_disabled rather than a 404, so a monitor
can tell "this relay does not answer that" from "there is no log
here". |
GET /e2e/log/subject?subject=<fnid> lists every
entry published under one account — publications and retirements, with
the device each names. It is unauthenticated, like the rest of §19: a
log only its own users can read is not a transparency log, and a monitor
is by definition somebody else.
⚠️ That has a cost, and it is stated here rather than left to be discovered. Anyone holding somebody's fnid — the address they hand to contacts — can see how many devices they have published and roughly when. This is inherent to key transparency rather than an oversight: a substitution cannot be detected in a log nobody may read. What it is not is a directory; you need the identifier first.
Scripts/watch-key-log.py is a self-hostable monitor,
forty lines of stdlib. 🔴 Run it somewhere you control — a hosted
service everybody is enrolled in would learn which accounts are worth
watching, which is a list nobody should be assembling.
⚠️ It reports what the relay serves. A relay showing one log to you and another to your contact is the fork §19.4's head gossip exists to catch, and no single watcher sees it; witness co-signing (relay#23) is the answer to that and does not exist yet. And a relay that stops answering is not an all-clear — the script says "could not reach", never "no changes".