Files
flamenet-relay/docker-compose.federated.yml

152 lines
6.0 KiB
YAML

# Flamenet relay, FEDERATED: your own server that exchanges mail with other
# relays, including relay.flamenet.io, while your accounts and mailboxes
# stay on your hardware.
#
# 1. cp .env.example .env and fill in the secrets it asks for,
# INCLUDING the "Federation" and "Calls (TURN)" sections
# 2. point DNS for $FN_PUBLIC_HOST at this machine (A/AAAA, no proxy CDN,
# TURN and long-polls both dislike middleboxes)
# 3. docker compose -f docker-compose.federated.yml up -d
# 4. open https://$FN_PUBLIC_HOST, the relay serves its own landing page
#
# What this adds over the plain docker-compose.yml:
# caddy , TLS on 443, automatic certificates. Federation peers dial your
# public hostname over HTTPS; a relay on 127.0.0.1:8080 federates
# with nobody.
# redis , rate limits and replay protection that survive a restart,
# and wake-ups for held polls when FN_RELAY_REPLICAS is above 1.
# One relay wakes held polls in memory.
# coturn , voice/video call relaying for peers behind NAT. Without it,
# calls connect only when both phones can reach each other
# directly, which across the public internet is "sometimes".
#
# What federation CANNOT give a self-hoster: iOS background push. Apple
# ties push credentials to the app's own developer team, so only the relay
# the shipped app was built for can wake a closed iPhone. On your relay, iOS
# receives when the app is open or foregrounded. Android's foreground
# service is unaffected and alerts in about a second either way. That is a
# platform constraint, stated here so you hear it from us and not from a
# confused tester.
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: flamenet
POSTGRES_USER: flamenet
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
volumes:
- db-data:/var/lib/postgresql/data
# Reached over the compose network only. An exposed database is the kind
# of default that ends up on Shodan.
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U flamenet -d flamenet"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
# Wake-up pub/sub only, nothing here needs to survive a restart, and a
# persistence file would be one more thing holding delivery metadata.
expose:
- "6379"
relay:
build: .
image: flamenet-relay:federated
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
FN_HOST: "0.0.0.0"
FN_LOG_LEVEL: "${FN_LOG_LEVEL:-error}"
FN_POSTGRES_URL: "postgres://flamenet:${POSTGRES_PASSWORD}@db:5432/flamenet"
FN_REDIS_URL: "redis://redis:6379"
# The audience IS your public hostname on a federated relay. Peers
# verify you by it, your users' addresses end in it, and tokens are
# bound to it, an audience that doesn't match the name peers dial is
# a relay that authenticates with nobody.
FN_RELAY_AUDIENCE: "${FN_PUBLIC_HOST:?set FN_PUBLIC_HOST in .env}"
FN_ISSUER_PUBLIC_KEY: "${FN_ISSUER_PUBLIC_KEY:?generate an issuer keypair - see .env.example}"
FN_ISSUER_PRIVATE_KEY: "${FN_ISSUER_PRIVATE_KEY:?generate an issuer keypair - see .env.example}"
# Federation proper, see the Federation section of .env.example.
FN_FEDERATION: "${FN_FEDERATION:-allowlist}"
FN_FEDERATION_PEERS: "${FN_FEDERATION_PEERS:-}"
FN_FEDERATION_KEY: "${FN_FEDERATION_KEY:?set FN_FEDERATION_KEY - see .env.example}"
# Calls. The secret must equal coturn's static-auth-secret below, the
# relay mints short-lived TURN credentials from it (§8.10).
FN_TURN_SECRET: "${FN_TURN_SECRET:?set FN_TURN_SECRET in .env}"
FN_TURN_HOST: "${FN_PUBLIC_HOST}"
# Ciphertext is deleted the moment the recipient acknowledges it.
# Postgres-only behaviour, and this profile is Postgres-only.
FN_DELETE_ON_ACK: "true"
# Everything else `.env` documents reaches the relay through the file
# itself. What is listed above wins over it.
env_file:
- .env
expose:
- "8080"
volumes:
- attachments:/data/attachments
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
# The whole reverse-proxy config is this one command: terminate TLS for
# the public hostname, hand everything to the relay. Certificates come
# from Let's Encrypt automatically; port 80 stays open for the challenge.
command: >
caddy reverse-proxy
--from ${FN_PUBLIC_HOST:?set FN_PUBLIC_HOST in .env}
--to relay:8080
volumes:
- caddy-data:/data
coturn:
image: coturn/coturn
restart: unless-stopped
# Host networking: TURN allocates relay ports dynamically, and publishing
# a 40-port range through the Docker proxy costs a process per port.
network_mode: host
# Client IP:port reaches this log. One stream and a size cap, rather
# than dated files inside the container that nothing ever deletes.
logging:
driver: json-file
options:
max-size: "1m"
max-file: "2"
command:
- "--no-cli"
- "--log-file=stdout"
- "--realm=${FN_PUBLIC_HOST}"
- "--fingerprint"
- "--use-auth-secret"
- "--static-auth-secret=${FN_TURN_SECRET}"
- "--listening-port=3478"
- "--listening-ip=0.0.0.0"
- "--min-port=49160"
- "--max-port=49200"
# Set this to the machine's PUBLIC address in .env. Behind NAT,
# coturn must know the address the outside world sees or every
# allocation hands out an unreachable candidate.
- "--external-ip=${FN_TURN_EXTERNAL_IP:?set FN_TURN_EXTERNAL_IP in .env}"
- "--no-tls"
- "--no-dtls"
- "--no-multicast-peers"
- "--no-rfc5780"
volumes:
db-data:
attachments:
caddy-data: