Open Free and open source — read the code

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".