LibrePortal/containers/stoat/docker-compose.yml
librelad 65167463f9 fix(chat apps): tag every service so its IP actually substitutes
Installing rocketchat failed with

    invalid IPv4 address: ParseAddr("IP_DATA_2"): unable to parse IP

ipUpdateComposeTags allocates one IP per SERVICE_TAG_N annotation and fills
IP_TAG_i only where SERVICE_TAG_i exists. The four new apps tagged only their
primary service, so every sidecar — matrix's postgres, mattermost's postgres,
rocketchat's mongo, and fifteen of stoat's sixteen — kept a literal IP_DATA_n
in the deployed compose and docker refused to create the container.

Tag every service that carries an ipv4_address, index-aligned with its IP_TAG.
For stoat that also meant moving caddy from SERVICE_TAG_1 to _6 so the indices
line up with the IPs rather than the reading order.

mastodon had the same latent break (IP_TAG_2 and _3 untagged) and is fixed the
same way — it would have failed on first install for the same reason.

SERVICE_TAG carries the compose *key*, not container_name: 'libreportal app
restart <app> <service>' passes it to 'docker compose restart', which only
understands keys.

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

384 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
# Required on Linux 6.19 and newer — see SERVER-121912. MongoDB 8.x
# bundles a TCMalloc whose restartable-sequence registration the
# newer kernel rejects, and mongod aborts on startup rather than
# degrading. Letting glibc own the rseq registration sidesteps it.
# Upstream Stoat hit the same wall (stoatchat/self-hosted#268).
- GLIBC_TUNABLES=glibc.pthread.rseq=1
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_1_DATA #LIBREPORTAL|STOAT_RABBITMQ_PASSWORD_1_TAG|STOAT_RABBITMQ_PASSWORD_1_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_1_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_1_TAG|STOAT_MINIO_PASSWORD_1_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_1_DATA #LIBREPORTAL|STOAT_MINIO_PASSWORD_1_TAG|STOAT_MINIO_PASSWORD_1_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