Open Free and open source, read the code Get it Donate App help Contact
Messenger
The app
Developers
Project
Legal

A flamenet.io account is not a Flamenet Messenger account. The two sign-ins are separate.

Self-hosting

Running your own relay is the point of this project, not a footnote to it. There is no hosted service to sign up for, no account with us, and nothing that phones home. Two containers and a config file.

The repositories

Everything this page describes is published under the MIT licence.

Repo What it is
flamenet-relay The server you are about to run
flamenet-e2e The protocol: JavaScript engine, specification, published test vectors
flamenet-messenger-ios The iOS app, and the source of truth for all of the above
flamenet-messenger-android The Android app, with its own Kotlin engine

You need the first one to run a relay. The rest are there so that "self-hostable" is something you can check rather than something you are told.

Quick start

git clone https://git.extant2000.com/extant2000/flamenet-relay
cd flamenet-relay
cp .env.example .env

Generate the three secrets .env asks for:

openssl rand -hex 32                       # POSTGRES_PASSWORD

openssl genpkey -algorithm ed25519 -out issuer.pem
openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64          # FN_ISSUER_PRIVATE_KEY
openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64  # FN_ISSUER_PUBLIC_KEY

-hex for the database password, not -base64, and this one is not cosmetic. It is interpolated into postgres://flamenet:<password>@db:5432/flamenet, where base64's /, + and = are all meaningful. A password containing / makes that URL invalid and the relay dies at start-up with a bare Code=-1000 and the connection string echoed back at you, which reads like the database is unreachable rather than like the password needs escaping. openssl rand -base64 32 produces one of those roughly three times in four, so following the old instructions usually failed.

Set FN_CONTACT_EMAIL to a mailbox you read, so the contact and safety pages name you rather than nobody, and start:

docker compose up -d

Every variable in .env reaches the relay. The compose file passes the whole file through, so a setting you add there is a setting the relay reads, and nothing is quietly dropped for not being on a list.

That pulls a prebuilt image, so there is no Swift toolchain to install and nothing to compile. The images are amd64 only: they are built by this project's CI, which runs on an x86_64 machine. On a Raspberry Pi or an ARM VPS, build from source instead:

docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build

That is a supported path rather than a fallback, and it is also the one to use if you would rather run what you can read than an image somebody else built. docs/REPRODUCIBLE.md says how far that checking currently goes, including where it stops.

Open http://localhost:8080. The relay serves its own landing page, so a working install explains itself instead of answering with a 404.

What those secrets are

  • FN_ISSUER_PRIVATE_KEY / FN_ISSUER_PUBLIC_KEY: an Ed25519 keypair. The relay issues short-lived tokens signed with the private half and verifies them with the public half, which is configuration and is never fetched over the network. Keep issuer.pem: losing it invalidates every token you have issued, and leaking it lets anyone mint tokens your relay accepts.
  • POSTGRES_PASSWORD: the database. Not published on the host by the compose file; the relay reaches it over the compose network.

Storage

Postgres by default in the compose file. The relay also runs on SQLite, which is what a single binary with no second process uses:

FN_ISSUER_PUBLIC_KEY=... FN_ISSUER_PRIVATE_KEY=... \
  flamenet-relay serve --hostname 0.0.0.0 --port 8080

Set FN_POSTGRES_URL and it uses Postgres; leave it unset and it writes relay.sqlite. The schema is identical either way.

Attachments are files rather than rows, so they live on a volume in both modes (FN_ATTACHMENTS_DIR, /data/attachments in the container).

Putting it behind TLS

The compose file binds to 127.0.0.1:8080 on purpose. Terminate TLS in front of it with whatever you already run. Caddy, nginx, Traefik, and point it at that port. Two things matter:

  • HTTPS is not optional in practice. Clients hold key material; serving them over plain HTTP hands it to anyone on the path.
  • Forward the client address. Sealed submissions are metered per recipient and per IP, and behind a proxy that does not set X-Forwarded-For every request looks like it came from the proxy.

Retention

The relay purges on a timer of its own, inside the process. Nothing external has to be scheduled, and there is no cron to wedge. The defaults:

Deleted after
Collected envelopes, on Postgres when the app's next poll confirms them
Collected envelopes the app never confirms, or any on SQLite 1 day
Undelivered envelopes 30 days
Attachments 30 days
Used one-time prekeys 7 days

Override any of them with FN_DELIVERED_RETENTION_DAYS, FN_UNDELIVERED_RETENTION_DAYS and FN_ATTACHMENT_RETENTION_DAYS. Deleting on confirmation is FN_DELETE_ON_ACK, on by default; 0 turns it off. It never runs on SQLite, which reuses row ids, so the cursor there would delete the next message unread.

None of this reaches your backups. A database dump or a WAL archive keeps what it captured for as long as you keep it, so your backup retention is part of your message retention.

There is no unencrypted message store any more, so FN_MESSAGE_RETENTION_DAYS and FN_READ_MESSAGE_RETENTION_DAYS are not read. Setting them does nothing; see docs/PLAINTEXT.md in the relay repository.

The compose files, and what each one runs

Five files, layered. docker-compose.yml is the base and the others are overlays, so -f docker-compose.yml -f <overlay> is the shape.

File Services For
docker-compose.yml db, relay The base. Publishes to 127.0.0.1; what the quick start uses.
docker-compose.build.yml relay Builds the image from source instead of pulling it. Needed on ARM.
docker-compose.prod.yml db, redis, relay, coturn, obfs4bridge A public relay: rate limiting that survives a restart, TURN for calls, and a bridge for networks that block the relay.
docker-compose.federated.yml db, redis, relay, caddy, coturn The same, with Caddy terminating TLS for you.
docker-compose.pooled.yml pgbouncer, relay Connection pooling, for when one Postgres is serving several relays. An overlay on prod, not on the base: it uses the internal network that file defines.

redis is optional and the relay says so at boot. Without it, rate limits are counted in process: correct for one relay, and reset by a restart. With it they are shared and durable. Nothing else depends on it.

obfs4bridge is for reachability, not privacy. It gives the relay a second address that does not look like a relay, for networks that block the first. It publishes its own port because it carries no SNI for a reverse proxy to route on. Clients paste the bridge line into Settings; see FN_OBFS4_* in docs/CONFIGURATION.md in the relay repository.

pgbouncer is only worth it above one relay. A single relay opens few enough connections that Postgres handles them directly. Layer it on prod:

docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  -f docker-compose.pooled.yml up -d

Voice calls

Optional, and only needed for calls.

docker-compose.yml, the file the steps above use, ships no coturn, on purpose: it publishes to 127.0.0.1 and is meant for working on the relay, where two clients on one machine reach each other directly. /e2e/turn answering 503 there is correct rather than a missing piece.

Calls across symmetric NAT and CGNAT need a relay in the middle, and that lives in docker-compose.prod.yml, behind a profile so nothing about calls runs, or has to be configured, unless you ask for it:

docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile calls up -d

Set FN_TURN_HOST, FN_TURN_SECRET and FN_TURN_EXTERNAL_IP in .env before you do; coturn refuses to start without them. The relay mints credentials from the same secret and never proxies media. Messaging works without any of it, and with the profile off /e2e/turn answers 503, which is a supported deployment rather than a fault.

Keeping it up to date

Running the published image: docker compose pull then docker compose up -d.

Building from source: git pull then docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build.

The --build matters when you are building from source. Compose reuses an existing image tag rather than rebuilding it, so without it a git pull brings down new source and leaves you running the old binary, no error, no warning, no update. Check what you are actually running with docker compose images relay.

Migrations run at boot, so there is no separate step. They are additive: a newer relay reads an older database and brings it forward. Take a dump first anyway, docker compose exec db pg_dump -U flamenet flamenet, because the reverse is not true, and an older relay against a newer database is not a supported direction.

One step by hand for older SQLite relays

This is for a relay on SQLite (no FN_POSTGRES_URL) that was already running before 25 September 2026. Postgres relays can skip it, and so can a SQLite relay first started after that date.

When email verification was added, the upgrade was meant to mark every account that already existed as verified, so that switching verification on later would lock nobody out. On SQLite that step failed without an error and marked nobody. Nothing changes until you set FN_EMAIL_VERIFICATION_REQUIRED. Once you do, everyone who signed up before that upgrade is turned away and asked for a code from an email that was never sent.

The relay now does this correctly, but each upgrade step runs once, and a relay that already ran the broken one will not run it again. Stop the relay, copy relay.sqlite somewhere safe (it is wherever FN_DB points, if you set it), open it with sqlite3 relay.sqlite, and run:

UPDATE users SET email_verified_at = CURRENT_TIMESTAMP
WHERE email_verified_at IS NULL
  AND (created_at IS NULL OR created_at <= (
    SELECT created_at FROM _fluent_migrations
    WHERE name = 'FlamenetRelay.AddEmailVerification'));

It marks only the accounts that existed before verification arrived. Anyone who signed up since still has to confirm their address. Running it a second time, or on a relay that never needed it, changes nothing.

Telling your users about updates

The Android app asks your relay whether a newer build exists, not an address of ours, because an app that says it does not phone home should not be checking in with us from every device. The side effect is that a relay which publishes nothing produces no update prompts at all.

Set four variables in .env and your relay publishes a release manifest that the app reads:

FN_ANDROID_VERSION_CODE=4
FN_ANDROID_VERSION_NAME=0.4
FN_ANDROID_SHA256=<sha256 of your APK>      # optional
FN_ANDROID_NOTES=What changed in this build.  # optional

Serve your APK at /downloads/flamenet-messenger.apk on the same host. The app builds the download URL from the relay's own origin and ignores anything the manifest says about where the file lives, so your relay can point your users at builds on itself, and at nothing else. Leave the variables unset and the route simply is not there, which is the right default: a relay should not invent a release its operator never chose.

Verifying what you are running

scripts/verify-build.sh              # digests of the engine sources
scripts/verify-build.sh --rebuild    # prove the vendored ML-KEM rebuilds byte-identically

Standing alone, or joining the federation

A relay you run is self-contained by default, and staying that way requires no configuration at all. FN_FEDERATION defaults to closed. Nothing is disabled by that choice: your users register, exchange keys, message each other, and verify your transparency log exactly as they would anywhere else. A closed relay is a complete product, not a crippled one.

It is also closed rather than nominally closed. 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.

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.

FN_FEDERATION What it means
closed (default) Your relay talks to your users and to nobody else.
allowlist Your relay exchanges traffic only with peers you name.
open Your relay accepts peers it has not met.

Your relay advertises which of these it is at /.well-known/flamenet/relay, so a peer reads your terms before attempting anything rather than discovering them from a rejected request.

Federation is not free, and the cost is metadata. Sealed sender hides who is writing from the relay carrying the message; it does not hide your relay's existence, or which relays it exchanges traffic with, from the operators on the other end. Joining a federation distributes a slice of your users' social graph across servers you do not run. That is a real reduction in what your users get from you hosting them, and it is the honest reason this defaults to off.

Choose it deliberately. If your relay exists so that one household, one company or one group of friends can talk without anyone else involved, closed is not the cautious option, it is the correct one.

Identities work either way

An account key and the fnid derived from it are worth having on a closed relay. They are what lets someone move: the address comes from a key your user's client derives from their passphrase, not from a row number you assigned, so the same person can register the same identity somewhere else and be recognised as themselves. That holds whether or not you ever federate, including if you shut your relay down, which is precisely when it matters.

What self-hosting actually buys you

It removes the operator from your threat model, because you are the operator. Nobody else holds the at-rest key, nobody else can be asked for your data, and nobody else decides when to retain it.

It does not make you anonymous to the people you talk to, and it does not change what any relay necessarily sees: recipients, timing, and approximate sizes. It also does not solve key distribution, your relay hands out prekey bundles, and safety numbers remain the check that does not require trusting it.

If you would rather not run a server

Hitt Hosting installs this relay from its app catalogue. You pick a name, set the same handful of options the quick start writes into .env, and it brings up the relay on a subdomain of its own, with a certificate, a Postgres database and an issuer keypair generated for that install and no other. Five dollars a month, or fifty a year, with a week to try it first.

It runs the published image, pinned to a commit rather than :latest, with a redis alongside it for held polls. What changes is who holds the box. Hitt Hosting can read whatever your relay can read, which is recipients, timing and approximate sizes, and it can be asked for them by somebody else. Sealed envelopes stay sealed, so nothing here weakens the encryption, but it puts an operator back in your threat model, and removing the operator is what the section above says self-hosting is for.

Two things differ from a relay you run yourself. Remote erase is off, and because the image is pinned, it updates when Hitt Hosting moves the pin rather than when you pull.

Free and open source · No ads · No phone number · Your own server if you want one