Files
Paul HittandClaude Opus 5 3ab3e8af03
ci / checks (pull_request) Failing after 10s
ci / image (pull_request) Skipped
ci / deploy (pull_request) Skipped
ci / swift (pull_request) Failing after 8m6s
Document FN_FUNDING_MANIFESTS, which CONFIGURATION.md promises to list
check-configuration-doc.py failed the PR: that file says it lists every
variable the relay reads, and the new one was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 16:14:43 -04:00

40 KiB

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 Flamenet credential that works across relays, 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

A self-hosted relay needs Postgres. SQLite is for local development only and receives no new features. Delete-on-ack already runs only on Postgres, because SQLite reuses row ids.

Variable Default Notes
FN_POSTGRES_URL unset Set it and Postgres is used. Required for any relay with real accounts.
FN_DB relay.sqlite SQLite file, used when the above is unset. Local development only.
FN_ATTACHMENTS_DIR attachments Blobs are files, not rows, in both modes.
FN_WORKDIR the process's own directory Where every relative path above is resolved from.
FN_S3_ENDPOINT unset Set it and attachments go to object storage instead of FN_ATTACHMENTS_DIR.
FN_S3_BUCKET, FN_S3_REGION, FN_S3_ACCESS_KEY_ID, FN_S3_SECRET_KEY unset All four are required once FN_S3_ENDPOINT is set. The relay refuses to start with a partial set: falling back to local disk with object storage half-configured would scatter attachments across two stores.
FN_S3_PREFIX attachments/ Key prefix inside the bucket.
FN_PG_MAX_CONNECTIONS_PER_LOOP 4 Postgres connections per event loop. Fluent's own default is one, eight total on an 8-core box, which a burst of concurrent sends queues behind.

Running more than one instance

One instance needs neither of these. Replay protection, the sign-in lockout and the attachment quota are held in memory, so a second instance without a shared store keeps its own copy of each.

Variable Default Notes
FN_REDIS_URL unset Shared store for the state above. Set but unusable, the log says so. With FN_RELAY_REPLICAS above 1 it also carries held-poll wake-ups between relays.
FN_REDIS_MAX_CONNECTIONS_PER_LOOP 32, or 512 with FN_RELAY_REPLICAS above 1 Redis connections per event loop. With several replicas each held poll pins one, so it is also the ceiling on held polls. One relay wakes held polls in memory.
FN_RELAY_REPLICAS 1 How many instances share this database. More than one with no FN_REDIS_URL is refused at boot: a single-use proof signature would be accepted once per instance, and an attacker would get that many times the sign-in attempts.

The contact graph

Variable Default Notes
FN_ROSTER_ON_SERVER true Whether the relay stores and serves the contact graph. false and it keeps neither; presence comes from tokens alone. Reversible, and nothing is destroyed by setting it: the irreversible step is the migration that drops the table, which is separate for that reason. §22 of the spec is the standing limit this switch narrows.

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.
FN_SENTRY_DSN unset Error reporting to a Sentry-protocol collector you run (GlitchTip), as http://<key>@<host>:<port>/<project>. Unset sends nothing. Set, errors, criticals and 5xx responses other than 503 (what a draining relay answers during a restart) each become one event: the message with account ids, device ids, addresses, tokens, emails and phone numbers removed, the error type, the route template (/e2e/devices/:deviceID, never the path asked for), method, status and the source line. No headers, bodies, query strings, addresses or logger metadata. Warnings stay on stdout; info and debug are never sent. At most 30 events a minute, one per fault per 60 seconds, 2 second timeout, no retries. The collector keeps what it receives for as long as it is configured to, so point it at one whose retention you have chosen. A value that is not a DSN stops the relay at start-up.
FN_RELEASE unset The release error reports are tagged with. Baked into the image at build time, normally the commit sha: docker build --build-arg FN_RELEASE=$(git rev-parse --short HEAD), or FN_RELEASE=<sha> Scripts/staging-gate.sh, which passes it on. Letters, digits and ._-+, at most 64 characters; anything else, or unset, and reports carry no release. Read only when FN_SENTRY_DSN is set.

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 peers, each host, host=key or host=key1|key2. The key is the peer's federation public key, base64, as its /.well-known/flamenet/relay publishes it in federationKey. A pinned peer whose descriptor publishes any other key is refused with 401. A bare host accepts whatever key the descriptor publishes, and boot logs a warning naming it. The host list is used only under allowlist; pins apply under allowlist and open. Enforced outbound as well as inbound, from the same rule. Peers named here are fetched wherever they resolve: the outbound address guard, which refuses private and loopback addresses, is for names an attacker can choose under open federation, not for a name you typed and pinned by key. Three relays on one host resolve each other to private addresses, and would otherwise refuse every deposit between them as unreachable.
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.

Pinning and rotating a peer's key

Read the peer's key and pin it:

curl -s https://peer.example/.well-known/flamenet/relay | jq -r .federationKey
FN_FEDERATION_PEERS=peer.example=<that key>

A peer rotating its key tells you the new public key first. Then:

  1. Pin both, old first: peer.example=<old>|<new>. Restart. Both are accepted.
  2. The peer switches FN_FEDERATION_KEY to the new seed and restarts.
  3. Wait an hour: the relay caches a peer's descriptor for 3600 seconds.
  4. Drop the old key: peer.example=<new>. Restart.

Rotating your own key is the same steps from the other side: send each peer the new public key, wait until all of them pin both, then switch.

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.

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_ID and FN_APNS_TEAM_ID are all set.
  • Federation, to peer relays, when FN_FEDERATION is allowlist or open. Sending to a peer also needs FN_FEDERATION_KEY.
  • Donations, to api.stripe.com, when FN_DONATE_SECRET is set.
  • Attachments, to an S3-compatible store, when FN_S3_ENDPOINT is set.
  • The Today file, to the address in FN_TODAY_SOURCE, about every fifteen minutes. Set FN_TODAY_FILE instead to serve one from disk and fetch nothing.

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.

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.
FN_ACCOUNT_TOKEN_ISSUER unset The iss an account-service token must carry. Required before strict mode can be switched on; the relay refuses to boot with strict on and this unset.
FN_ACCOUNT_TOKEN_STRICT false Off, a token without iss, aud and jti, or with the wrong ones, is logged, counted and accepted. On, it is refused with 401, and each jti is accepted once until the token expires. Turn it on only after the account service stamps all three (steps in SECURITY.md).

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 600/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 60/hr Per requester-target pair. Deliberately not metered per target alone, that would let one abuser make a popular account unstartable for everyone. The anonymous POST /e2e/anon/bundles uses the same two numbers per address and per address-target pair, or this one per presence token when the caller presents one.
FN_SIGNIN_MAX_PER_IP 30 per 15 min Failed sign-ins from one address across every username, beside the per-address-and-username throttle. A success clears only the per-username count. 0 switches it off.
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 was Android 0.6 when this landed, and the client-side solver came after it. 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 3000/hr Envelopes per sender for an identified send. For a sealed send, envelopes per address, and the recipient-wide bucket, which since 2026-10-07 counts only refused sealed attempts (one per request): it exists to stop guessing, and when it counted accepted envelopes too, anybody holding one working delivery key could fill it and nobody else could reach that person for the hour. Once it is full, sealed sends to that person are refused for the rest of the hour whatever they carry, so a guess cannot be checked.
FN_SEND_RATE_RECIPIENT_IP FN_SEND_RATE / 10 Sealed envelopes from one address to one recipient. What bounds a sender with a working key now that accepted sends no longer fill the recipient-wide bucket, and what stops one address filling that bucket with refusals on its own. Keyed by a keyed hash of the recipient, never the account id.
FN_INTRO_RATE 30/hr Introductions (POST /e2e/intro) per address. Charged before the handle or link is looked up, so a listed name, an unlisted one and an unknown one cost the same.
FN_INTRO_RATE_NETWORK 240/hr Introductions per network, a /24 for IPv4 and a /48 for IPv6, the sign-up buckets.
FN_INTRO_RATE_TARGET 30/hr Introductions per account, and a third of it per network and account, so one network cannot spend an account's whole hour. A crowd of networks still can: an account being flooded with introductions stops getting new ones for the hour, and the answer for that is Unlisted plus contact links. Separately, at most 30 unspent introductions (one token each) per account at once.
FN_INTRO_POW_BITS 16 Leading zero bits of SHA-256 work an introduction by handle must show (decision DD2), from GET /e2e/intro/challenge; each solution is accepted once. Capped at 24. 0 switches it off. An introduction by contact link needs none: the link is the permission.
FN_ATTACHMENT_RATE 60/hr
FN_ATTACHMENT_BYTES_RATE 200 MiB/hr
FN_ATTACHMENT_MAX_BYTES 10 MiB Per blob.
FN_ATTACHMENT_BYTES_PER_MONTH 500 MiB Per account, across a calendar month. Held in memory unless FN_REDIS_URL is set, so see the replicas section before running more than one instance.
FN_MAX_DEVICES 3 Devices one account may have registered at once.
FN_DEVICE_EXPIRY_DAYS 90 A device that has not polled in this long is retired, and its prekeys stop being served.
FN_CAPACITY_CEILING unset A number of users the operator wants /status measured against. Advisory: nothing is refused when it is passed, the page says where it stands.

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 shipped client honours a wipe or 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.
FN_REPORTS_TO unset Where the report digest is mailed, through the SMTP settings. One message lists every report filed since the last one, at most one an hour, checked every 5 minutes; an hour with no reports sends nothing. At most 200 reports per message, each note and copy cut at 1,000 characters. A digest that fails to send is retried with the same reports. The watermark is the report_digests table, so a restart neither loses nor repeats a report. Unset, reports are kept in content_reports and read with Scripts/reports.sh, and the log says nobody was told.
FN_REPORT_RATE 10/hr Reports one account may file per hour. Over it, the route answers 429 and nothing is stored.
FN_REPORT_RETENTION_DAYS 365 How long a content report, and the plaintext copy the reporter chose to attach, is kept. Long enough to show a pattern, bounded so it is not forever. Shown on /metadata.

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.

Suspending an account

Scripts/suspend.sh <handle> on the relay host, and --lift to undo it. A suspended account cannot sign in, cannot mint a credential, and every request it makes with a credential it already holds is refused with account_suspended (403), so it takes effect on the next request rather than when a token expires. Messages, keys and devices are untouched: this is a door, not a deletion, and lifting it puts everything back. --list says who is suspended and since when. The /safety page tells people this exists.

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.

The Android app cannot finish this. The relay refuses the token with email_unverified and a sentence saying to enter the code. The iPhone app shows that sentence and a sheet to enter it in; the Android app maps the refusal to RelayException.EmailUnverified and nothing catches it, and RelayAccounts.verifyEmail, which would spend the code, has no call site. So turning this on locks every Android user out of a relay they can sign up to, with no way forward in the app. It is off by default for other reasons, and this is the one to know about before turning it on.

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. Android 0.6 (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_DELETE_ON_ACK on Delete a collected envelope the moment the recipient's app confirms it, which is its next poll. Postgres only: on SQLite it stays off whatever this says, because SQLite reuses row ids and the cursor would delete the next message unread. 0, false, no or off turns it off.
FN_PAIRS_CLEANUP unset 1 runs the two irreversible pairs stage 2-A clean-ups at the next boot: clear the stars and archives stored on the relay, and forget who issued and redeemed each spent invite code. Off so the relay can ship while people on older apps still keep stars here. Once run they are recorded and never run again. Take a database backup first.
FN_DELIVERED_RETENTION_DAYS 1 The backstop for a collected envelope whose app never polls again to confirm it, and the only window when delete-on-ack is off.
FN_UNDELIVERED_RETENTION_DAYS 30 Must stay below the client's retired-signed-prekey retention of 45 days.
FN_ATTACHMENT_RETENTION_DAYS 30

Notifications are swept on the same timer with fixed windows: a read one after 7 days, any one after 30.

Padding

Groundwork for length hiding (spec §23). The apps pad each envelope payload to 16384 or 65536 characters; the relay only counts, and refuses when told to. Every sweep line carries pad_in and pad_out (payloads on and off that ladder, by app build) and pad_lengths (a coarse histogram), all count-only. Scripts/payload-lengths.sh sums them over several days. A poll returns at most about 2 MiB of payload, never fewer than one envelope, and adds "more": true when it stopped early.

Variable Default Notes
FN_PAD_ENFORCE off off accepts any length. log accepts it and logs one count-only line per request that would have been refused. enforce refuses the request with code unpadded. Anything else reads as off. Do not set enforce until the counts show every build still sending is padding: it refuses every message from an app that does not.
FN_PAD_ATTACHMENTS false Published as features.padAttachments in /.well-known/flamenet/relay. Apps pad attachments only when it is true. An app older than the one that reads padded attachments cannot open them, so leave it false until those have gone.

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, FN_SMTP_PASSWORD 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.

The Today file

Off. When it is on, the relay serves one public file at GET /today.json (§8.12): headlines, markets, weather and the like, built and signed somewhere else, and the same bytes for everybody. The relay checks the signature and serves the file exactly as it arrived.

Variable Default Notes
FN_TODAY_FILE unset Path to the signed file. Read again whenever it changes (its modification time, size or inode). Write a new file and rename it over the old one, so the relay never reads a half-written one. Relative paths resolve under FN_WORKDIR.
FN_TODAY_SOURCE unset An http or https URL to fetch the file from instead, every 15 minutes give or take a minute, with a 10 second limit and a 512 KB cap. Set this or FN_TODAY_FILE, not both: with both set nothing is served and the log says why. A self-hosted relay mirrors the official file by pointing this at another relay's /today.json.
FN_TODAY_KEYS unset Comma-separated base64 Ed25519 public keys the file may be signed with. An envelope names its key by the first eight hex characters of the key's SHA-256. A file signed by any other key, or by none, is never served.
FN_TODAY_CACHE unset Path where the last file that verified is kept, so a restart can serve it straight away. It is checked against FN_TODAY_KEYS again when it is read back. Relative paths resolve under FN_WORKDIR.

Nothing is served until it verifies, and a file that fails the check never replaces one that passed. A bad signature, a key that is not listed, a file over 512 KB, a source that is down or slow: the relay goes on serving the last good file, and logs the refusal, once for each new reason. With no source set, /today.json answers 404 {"error":"not_offered"}; with a source set and nothing verified yet, 503.

The route reads no credential, no cookie and no query, and writes no log line per request. A good answer carries Cache-Control: public, max-age=900 and an ETag, and If-None-Match gets a 304.

FN_TODAY_SOURCE makes the relay open a connection of its own: one GET to that URL every fifteen minutes. Whatever comes back still has to verify, so the host at that URL cannot change what the file says. It can only hold back a newer file or hand back an older signed one.

iPhone push (APNs)

Only the iOS app needs this; Android is woken directly. All five must be set together, and the relay logs that push is disabled if any is missing.

Variable Default Notes
FN_APNS_KEY_P8 unset Path to the .p8 signing key from Apple. Mount it as a secret; it is not a file to bake into an image.
FN_APNS_KEY_ID unset The key's ten-character id.
FN_APNS_TEAM_ID unset Your Apple developer team id.
FN_APNS_TOPIC net.flamenet.messenger The app's bundle id. Change it if you ship your own build.
FN_APNS_ENV sandbox sandbox or production. A token from a TestFlight or App Store build is meaningless to the sandbox host and the other way round, and APNs returns the same 400 either way. Getting this wrong means no iPhone is ever woken, so set it to production the moment you ship a build that is not from Xcode.

Behind a proxy

Variable Default Notes
FN_TRUSTED_PROXIES 1 How many proxy hops to trust in X-Forwarded-For, counted from the right, when the request comes from a trusted proxy (FN_TRUSTED_PROXY_ADDRS). 1 takes the last entry, right behind one proxy that replaces the header rather than appending to what the client sent (Traefik does, with no forwardedHeaders trust configured). Never set it higher than the number of proxies you run: a client can forge the header for any hop trusted beyond them. 0 ignores the header even from a trusted proxy.
FN_TRUSTED_PROXY_ADDRS unset The proxies whose X-Forwarded-For the relay reads: comma-separated addresses or CIDR ranges, IPv4 or IPv6 (172.18.255.241, 10.0.0.0/24). The relay compares each request's TCP peer against it. From any other peer the header is ignored and the peer's own address keys every per-IP limit. Unset, nobody is trusted: a relay exposed directly cannot be given a false address, and a relay behind a proxy meters everyone as the proxy until this is set. List your edge proxy's fixed address and nothing wider: a container sharing the proxy's network can reach the relay directly and write any header it likes. Pin that address in the proxy's own compose, or a restart can hand it to another container. An entry that does not parse matches nothing.

Pluggable transport (obfs4)

Reachability, not privacy: a second address that does not look like a relay, for networks that block the first. It is what docker-compose.prod.yml runs under the obfs4 profile, and the variables are read by that compose file rather than by the relay.

Variable Default Notes
FN_OBFS4_PORT 8443 Published on every interface, unlike the relay. A bridge only loopback can reach helps nobody; what makes that safe is that the protocol answers nobody who cannot complete a handshake.
FN_OBFS4_ADVERTISE unset The public address to put in the bridge line handed to clients. A container cannot know its own, so without this the line says <this-host> and is not dialable.
FN_OBFS4_IMAGE flamenet-obfs4bridge:prod Set it to an image you can pull.
FN_OBFS4_CONTEXT ../flamenet-elligator2 Build context, a sibling checkout of the public flamenet-elligator2 repository (MIT).
FN_OBFS4_UPSTREAM traefik:443 Where the bridge forwards the bytes from inside the tunnel. The apps send TLS for the relay's hostname, so this must be the TLS front door (your edge proxy on 443), not the relay's plain HTTP port. Pointed at relay:8080, the handshake succeeds and a plain-HTTP check passes, but no app session completes.
FN_OBFS4_PROXY_PROTOCOL 1 Send a PROXY protocol v1 line with each client's address before its bytes, so the edge proxy applies country rules, rate limits and bans per client rather than to everyone behind the bridge. The edge proxy must trust exactly the bridge's address for it (Traefik: entryPoints.websecure.proxyProtocol.trustedIPs set to FN_OBFS4_WEB_IP/32). Set 0 if yours is not configured for it, or every bridged connection is dropped.
FN_OBFS4_WEB_IP 172.18.255.240 The bridge's fixed address on the edge proxy's network. Fixed because the PROXY-protocol trust is pinned to it; an unpinned address can pass to another container after a restart, and so would the trust. Pick a free address in your web network's subnet.

The bridge's state volume holds its identity key, which clients pin through the cert in the bridge line. Losing it invalidates every bridge line you have handed out.

The bridges you run are listed in the relay's database, in the obfs4_bridges table: host, port, fingerprint (the 40-hex node ID), the line, when it was added and when it was retired. No route reads it. On the relay host, Scripts/bridges.sh add <host> <port> lists the bridge beside the relay, deriving its line from the state volume; Scripts/bridges.sh list prints what is in service (--all adds retired ones) and Scripts/bridges.sh retire <fingerprint> takes one out of service. A retired identity is never listed again. The script header shows how to list a bridge on another machine.

Metrics

Off. Set FN_METRICS_PORT and the relay opens a second listener that answers GET /metrics in the Prometheus text format and nothing else. The public port never serves /metrics, and the relay refuses to boot when the two ports are the same.

Variable Default Notes
FN_METRICS_PORT unset (off) Port for the metrics listener.
FN_METRICS_HOST 127.0.0.1 Bind address. In Docker, set 0.0.0.0 so a scraper on the same network can reach it, and do not publish the port.
Metric Type Counts
flamenet_held_polls gauge Long-polls held open now.
flamenet_push_sent_total counter APNs pushes Apple accepted.
flamenet_push_failed_total counter APNs pushes refused, unreachable or not signed.
flamenet_push_dropped_total counter Pushes dropped because the push queue was full.
flamenet_limiter_fail_open_total counter Rate-limit checks allowed because Redis did not answer.
flamenet_envelopes_written_total counter Envelopes committed.
flamenet_federation_deposits_in_total counter Deposits accepted from peers.
flamenet_federation_deposits_out_total counter Deposits a peer accepted from this relay.

Counters start at zero with the process. No metric carries an id, a name or an address.

Site copy

Variable Default Notes
FN_IOS_LIVE false Flips clauses on the public pages that describe iPhone availability. Turning it on states general availability, so it belongs on after an App Store release and not during a beta.
FN_IOS_STORE_URL unset The iPhone app's App Store page. With FN_IOS_LIVE on and this set, the download buttons on the landing and get pages point here instead of TestFlight; with either missing they keep pointing at TestFlight, which is where the app still is.
FN_DISPOSABLE_EMAIL_DOMAINS unset Comma-separated domains refused at sign-up. Empty means none are.
FN_FEDERATION_SCREEN unset Advisory IP screening for federated peers. It does not gate federation; the allowlist and the pinned key do.
FN_CONTACT_EMAIL unset, then FN_REPORTS_TO Who runs this relay, on the contact, safety and terms pages and in /.well-known/security.txt. Nothing on those pages is a literal: an unconfigured relay says no address is published instead of naming somebody else.
FN_CONTACT_SUPPORT, FN_CONTACT_ABUSE, FN_CONTACT_SECURITY, FN_CONTACT_PRIVACY, FN_CONTACT_LEGAL, FN_CONTACT_POSTMASTER FN_CONTACT_EMAIL One role at a time, for an operator who keeps separate mailboxes.
FN_FUNDING_MANIFESTS unset The funding.json manifests this domain vouches for, whitespace or comma separated, served at /.well-known/funding-manifest-urls. Anything not starting with https:// is dropped. Unset, that path is a 404: the manifest carries an operator's legal name, contact address and bank details, so a relay somebody else runs must not tell a funder that donations for it reach us.
FN_ANDROID_VERSION_CODE, FN_ANDROID_VERSION_NAME unset The Android build the download page and /api/android/release advertise. Both are required for either to appear: a version name with no code is not a release anyone can check.
FN_ANDROID_SHA256 unset The APK's checksum, published so somebody can verify what they downloaded.
FN_ANDROID_NOTES empty Release notes for that build.

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