LibrePortal/containers/stoat/docker-compose.yml
librelad 4685320353 feat(secrets): real VAPID keypair for mastodon, slot-numbered DB passwords
VAPID: the two values are the halves of one P-256 keypair, not independent
secrets — the browser verifies that a push is signed by the private key matching
the public key it subscribed with. The RANDOMIZED* generators mint each
placeholder on its own, so they produced two unrelated strings and web push could
never have worked. Generate the pair in mastodon_install_post_setup the way stoat
already does, encoded as Mastodon's webpush gem expects: unpadded URL-safe base64
of the 32-byte private scalar and the 65-byte uncompressed public point, sliced
out of the SEC1 DER. Verified by rebuilding the key from the emitted private half
and re-deriving the public point — openssl accepts it and the point matches.

Generated once and never rotated (rotation would invalidate every subscription),
but a pair of the wrong shape is replaced, so an install carrying the old
unrelated strings heals itself on next install — their public half is 42 chars
where a real point is 87.

Slots: CFG_<APP>_DB_PASSWORD -> CFG_<APP>_DB_PASSWORD_1 and likewise for
DB_ROOT_PASSWORD, across mastodon, owncloud, mattermost, matrix, nextcloud and
bookstack, so a database credential is always a numbered slot and a second one is
just _2. Renaming a key means reconciliation drops the old and adds the new
holding its placeholder, so an existing install regenerates unless the value is
carried over first — documented, including that the old file survives as
.<app>.config.bak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:42:50 +01:00

378 lines
17 KiB
YAML

# Stoat (formerly Revolt) — the closest open-source equivalent to Discord's
# model of servers, channels, roles and voice.
#
# Layout note, because it looks inconsistent at first glance: the compose
# *service keys* below (database, redis, api, autumn, ...) are deliberately kept
# identical to upstream's compose.yml, while container_name is prefixed with
# stoat- so nothing collides with other LibrePortal apps. Compose registers both
# the service key and the container name on the network, so upstream's internal
# defaults — the S3 endpoint baked into the file server, MINIO_DOMAIN, the
# service names in Revolt.toml — keep resolving, and LibrePortal still gets the
# prefixed container names its port, firewall and backup layers key on.
#
# SERVICE_TAG_n carries the compose *key*, not the container name, for two
# reasons: ipUpdateComposeTags allocates exactly one IP per SERVICE_TAG and fills
# IP_TAG_i only where SERVICE_TAG_i exists (so every service with an
# ipv4_address needs one, index-aligned, or it deploys with a literal
# IP_DATA_n and compose refuses to start), and `libreportal app restart <app>
# <service>` passes the value straight to `docker compose restart`, which only
# understands keys.
#
# Upstream fronts the whole stack with Caddy doing path routing (/api, /ws,
# /autumn, ...). That is kept as-is and Traefik simply proxies to it, which is
# upstream's own supported "behind another reverse proxy" mode — reimplementing
# eight path routes as Traefik labels would be a second copy to keep in sync for
# no benefit. Caddy is given HOSTNAME=:80 by the install hook so it serves plain
# HTTP and never tries to obtain its own certificate.
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
# MongoDB — primary datastore.
#
# Upstream's healthcheck is kept rather than the LibrePortal HEALTHCHECK_TAG:
# half the stack uses `depends_on: condition: service_healthy` against it, so
# disabling the healthcheck would deadlock the boot order. Same for rabbit.
database: #LIBREPORTAL|SERVICE_TAG_1|database
container_name: stoat-database
image: mongo:8.0 #LIBREPORTAL|STOAT_MONGO_VERSION_TAG|8.0
restart: unless-stopped
volumes:
- ./data/db:/data/db
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
start_period: 10s
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# Valkey — event message broker and KV store.
redis: #LIBREPORTAL|SERVICE_TAG_2|redis
container_name: stoat-redis
image: valkey/valkey:9-alpine #LIBREPORTAL|STOAT_VALKEY_VERSION_TAG|9-alpine
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2
# RabbitMQ — internal message broker (push notifications, voice events).
rabbit: #LIBREPORTAL|SERVICE_TAG_3|rabbit
container_name: stoat-rabbit
image: rabbitmq:4-alpine #LIBREPORTAL|STOAT_RABBITMQ_VERSION_TAG|4-alpine
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- RABBITMQ_DEFAULT_USER=stoat
- RABBITMQ_DEFAULT_PASS=STOAT_RABBITMQ_PASSWORD_DATA #LIBREPORTAL|STOAT_RABBITMQ_PASSWORD_TAG|STOAT_RABBITMQ_PASSWORD_DATA
volumes:
- ./data/rabbit:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 10s
timeout: 10s
retries: 3
start_period: 20s
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3
# MinIO — S3-compatible object storage for uploads and avatars.
#
# The bucket-name aliases are load-bearing: the file server addresses
# buckets virtual-host style (<bucket>.minio), so without these the DNS
# lookup fails and every upload errors.
minio: #LIBREPORTAL|SERVICE_TAG_4|minio
container_name: stoat-minio
image: minio/minio:latest #LIBREPORTAL|STOAT_MINIO_VERSION_TAG|latest
restart: unless-stopped
command: server /data
volumes:
- ./data/minio:/data
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- MINIO_ROOT_USER=stoatminio
- MINIO_ROOT_PASSWORD=STOAT_MINIO_PASSWORD_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_TAG|STOAT_MINIO_PASSWORD_DATA
- MINIO_DOMAIN=minio
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_4 #LIBREPORTAL|IP_TAG_4|IP_DATA_4
aliases:
- minio
- revolt-uploads.minio
# Legacy bucket names, kept because instances created before
# the consolidation still address them.
- attachments.minio
- avatars.minio
- backgrounds.minio
- icons.minio
- banners.minio
- emojis.minio
# One-shot: creates the uploads bucket, then exits. Not a failure when you
# see it stopped.
createbuckets: #LIBREPORTAL|SERVICE_TAG_5|createbuckets
container_name: stoat-createbuckets
image: minio/mc:latest #LIBREPORTAL|STOAT_MINIO_MC_VERSION_TAG|latest
depends_on:
- minio
# Credentials come in through the environment rather than being written
# into the entrypoint: a #LIBREPORTAL annotation only substitutes on the
# line it sits on, and inside a folded block scalar it would end up as
# literal text in the command anyway. $$ escapes the dollar so compose
# leaves it for the container's shell instead of interpolating it here.
environment:
- MC_USER=stoatminio
- MC_PASS=STOAT_MINIO_PASSWORD_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_TAG|STOAT_MINIO_PASSWORD_DATA
entrypoint: >
/bin/sh -c "
while ! /usr/bin/mc ready minio; do
/usr/bin/mc alias set minio http://minio:9000 $$MC_USER $$MC_PASS;
echo 'Waiting minio...' && sleep 1;
done;
/usr/bin/mc mb --ignore-existing minio/revolt-uploads;
exit 0;
"
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_5 #LIBREPORTAL|IP_TAG_5|IP_DATA_5
# Caddy — internal path router for the whole stack. This is the only service
# Traefik talks to, and the only one carrying a Traefik router.
caddy: #LIBREPORTAL|SERVICE_TAG_6|caddy
container_name: stoat-caddy
image: caddy:2-alpine #LIBREPORTAL|STOAT_CADDY_VERSION_TAG|2-alpine
restart: unless-stopped
env_file: .env.web
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
# GLUETUN_OFF_END
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./stoat.json:/stoat.json:ro
- ./data/caddy-data:/data
- ./data/caddy-config:/config
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
libreportal.backup.db: "mongo:stoat-database:data/db:"
# Uploads live in MinIO, not on a filesystem the file server owns,
# so the object store's own data dir is what has to be captured.
libreportal.backup.files: "stoat-minio:/data:data/minio"
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.stoat-caddy.entrypoints: web,websecure
traefik.http.routers.stoat-caddy.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.stoat-caddy.tls: true
traefik.http.routers.stoat-caddy.tls.certresolver: production
traefik.http.services.stoat-caddy.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.stoat-caddy.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
# TRAEFIK_PORT_1_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
healthcheck:
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_6 #LIBREPORTAL|IP_TAG_6|IP_DATA_6
aliases:
- caddy
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
# API server.
api: #LIBREPORTAL|SERVICE_TAG_7|api
container_name: stoat-api
image: ghcr.io/stoatchat/api:v0.15.1 #LIBREPORTAL|STOAT_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
redis:
condition: service_started
rabbit:
condition: service_healthy
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_7 #LIBREPORTAL|IP_TAG_7|IP_DATA_7
aliases:
- api
# Websocket / events service.
events: #LIBREPORTAL|SERVICE_TAG_8|events
container_name: stoat-events
image: ghcr.io/stoatchat/events:v0.15.1 #LIBREPORTAL|STOAT_EVENTS_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_8 #LIBREPORTAL|IP_TAG_8|IP_DATA_8
aliases:
- events
# Autumn — file server.
autumn: #LIBREPORTAL|SERVICE_TAG_9|autumn
container_name: stoat-autumn
image: ghcr.io/stoatchat/file-server:v0.15.1 #LIBREPORTAL|STOAT_AUTUMN_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
createbuckets:
condition: service_started
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_9 #LIBREPORTAL|IP_TAG_9|IP_DATA_9
aliases:
- autumn
# January — link metadata and image proxy.
january: #LIBREPORTAL|SERVICE_TAG_10|january
container_name: stoat-january
image: ghcr.io/stoatchat/proxy:v0.15.1 #LIBREPORTAL|STOAT_JANUARY_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_10 #LIBREPORTAL|IP_TAG_10|IP_DATA_10
aliases:
- january
# Gifbox — Tenor proxy for the GIF picker. Inert until a Tenor API key is
# added to secrets.env; see the upstream Guides.md.
gifbox: #LIBREPORTAL|SERVICE_TAG_11|gifbox
container_name: stoat-gifbox
image: ghcr.io/stoatchat/gifbox:v0.15.1 #LIBREPORTAL|STOAT_GIFBOX_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_11 #LIBREPORTAL|IP_TAG_11|IP_DATA_11
aliases:
- gifbox
# Scheduled task daemon.
crond: #LIBREPORTAL|SERVICE_TAG_12|crond
container_name: stoat-crond
image: ghcr.io/stoatchat/crond:v0.15.1 #LIBREPORTAL|STOAT_CROND_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
minio:
condition: service_started
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_12 #LIBREPORTAL|IP_TAG_12|IP_DATA_12
# Push notification daemon.
pushd: #LIBREPORTAL|SERVICE_TAG_13|pushd
container_name: stoat-pushd
image: ghcr.io/stoatchat/pushd:v0.15.1 #LIBREPORTAL|STOAT_PUSHD_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
redis:
condition: service_started
rabbit:
condition: service_healthy
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_13 #LIBREPORTAL|IP_TAG_13|IP_DATA_13
# Voice ingress daemon — receives LiveKit's webhooks.
voice-ingress: #LIBREPORTAL|SERVICE_TAG_14|voice-ingress
container_name: stoat-voice-ingress
image: ghcr.io/stoatchat/voice-ingress:v0.15.1 #LIBREPORTAL|STOAT_VOICE_INGRESS_VERSION_TAG|v0.15.1
restart: unless-stopped
env_file: secrets.env
depends_on:
database:
condition: service_healthy
rabbit:
condition: service_healthy
volumes:
- ./Revolt.toml:/Revolt.toml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_14 #LIBREPORTAL|IP_TAG_14|IP_DATA_14
aliases:
- voice-ingress
# LiveKit — the WebRTC SFU behind voice and video.
#
# Its media ports cannot go through Traefik: WebRTC is UDP, and Traefik is
# an HTTP proxy. The TCP fallback port is declared in stoat.config so the
# port and firewall layers manage it. The UDP range is published literally
# below because LibrePortal's port table stores one port per row and its
# firewall rebuild emits /tcp rules only — a range declared there would
# produce a wrong rule rather than no rule. Open it yourself if voice needs
# to work from outside the LAN:
# sudo ufw allow 50000:50100/udp
# Voice still falls back to TCP 7881 without it, at the cost of latency.
livekit: #LIBREPORTAL|SERVICE_TAG_15|livekit
container_name: stoat-livekit
image: ghcr.io/stoatchat/livekit-server:v1.9.13 #LIBREPORTAL|STOAT_LIVEKIT_VERSION_TAG|v1.9.13
restart: unless-stopped
command: --config /etc/livekit.yml
depends_on:
redis:
condition: service_started
ports:
- "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2
- "50000-50100:50000-50100/udp"
volumes:
- ./livekit.yml:/etc/livekit.yml:ro
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_15 #LIBREPORTAL|IP_TAG_15|IP_DATA_15
aliases:
- livekit
# The web client itself. Served by Caddy at /.
web: #LIBREPORTAL|SERVICE_TAG_16|web
container_name: stoat-web
image: ghcr.io/stoatchat/for-web:0c31cf0 #LIBREPORTAL|STOAT_WEB_VERSION_TAG|0c31cf0
restart: unless-stopped
env_file: .env.web
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_16 #LIBREPORTAL|IP_TAG_16|IP_DATA_16
aliases:
- web