Initial recipe: lasuite-meet 0.1.0+1.8.0

This commit is contained in:
notplants
2026-02-27 16:48:29 +00:00
commit 94c657e976
10 changed files with 642 additions and 0 deletions

80
.env.sample Normal file
View File

@ -0,0 +1,80 @@
TYPE=lasuite-meet
DOMAIN=lasuite-meet.example.com
## Domain aliases
#EXTRA_DOMAINS=', `www.lasuite-meet.example.com`'
LETS_ENCRYPT_ENV=production
## LiveKit domain — separate domain for WebSocket signaling
## Clients connect to wss://LIVEKIT_DOMAIN for video/audio
LIVEKIT_DOMAIN=livekit.example.com
##############################################################################
# SECRETS
##############################################################################
# abbreviations are to fit abra 12 char secret recommendation
# DJANGO_SECRET_KEY
SECRET_DJANGO_SK_VERSION=v1
# OIDC_RP_CLIENT_SECRET
SECRET_OIDC_RPCS_VERSION=v1
# DJANGO_SUPERUSER_PASSWORD
SECRET_DJANGO_SP_VERSION=v1
# POSTGRES_PASSWORD
SECRET_POSTGRES_P_VERSION=v1
# LIVEKIT_API_SECRET
SECRET_LIVEKIT_AS_VERSION=v1
# DJANGO_EMAIL_HOST_PASSWORD
SECRET_EMAIL_PASS_VERSION=v1
##############################################################################
# EMAIL
##############################################################################
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mail.example.com"
DJANGO_EMAIL_LOGO_IMG="https://${DOMAIN}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_USE_SSL=False
DJANGO_EMAIL_USE_TLS=True
DJANGO_EMAIL_FROM=meet@example.com
#DJANGO_EMAIL_HOST_USER=
##############################################################################
# SINGLE SIGN ON
##############################################################################
# NOTE: OpenID Connect (OIDC) single sign-on is **required**, see recipe README
OIDC_REALM=lasuite-meet
AUTH_DOMAIN=keycloak.example.com
OIDC_OP_JWKS_ENDPOINT=https://${AUTH_DOMAIN}/realms/${OIDC_REALM}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${AUTH_DOMAIN}/realms/${OIDC_REALM}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${AUTH_DOMAIN}/realms/${OIDC_REALM}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${AUTH_DOMAIN}/realms/${OIDC_REALM}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${AUTH_DOMAIN}/realms/${OIDC_REALM}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=meet
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://${DOMAIN}
LOGIN_REDIRECT_URL_FAILURE=https://${DOMAIN}
LOGOUT_REDIRECT_URL=https://${DOMAIN}
OIDC_REDIRECT_ALLOWED_HOSTS='["https://${DOMAIN}"]'
OIDC_AUTH_REQUEST_EXTRA_PARAMS='{"acr_values": "eidas1"}'
##############################################################################
# LIVEKIT
##############################################################################
LIVEKIT_API_KEY=meet
#ALLOW_UNREGISTERED_ROOMS=False
##############################################################################
# LOGGING
##############################################################################
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
##############################################################################
# MIGRATIONS
##############################################################################
# Set to false to disable automatic migrations on backend startup
# AUTO_MIGRATIONS=true

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# La Suite Meet
Video conferencing for [La Suite Numérique](https://lasuite.numerique.gouv.fr/), built on [LiveKit](https://livekit.io/) WebRTC and Django.
**Upstream:** https://github.com/suitenumerique/meet
## Requirements
- An OIDC provider (e.g. Keycloak) for authentication — there is no local login
- A dedicated domain for LiveKit WebSocket signaling (e.g. `livekit.example.com`)
- Firewall ports open: TCP 7881 (WebRTC ICE/TCP), UDP 7882 (WebRTC/UDP)
## Network ports
This recipe publishes two ports directly on the host for WebRTC media transport:
| Port | Protocol | Purpose |
|------|----------|---------|
| 7881 | TCP | WebRTC ICE over TCP (fallback when UDP is blocked) |
| 7882 | UDP | WebRTC ICE over UDP (primary media transport) |
These ports carry raw RTP media packets and cannot be routed through Traefik. The WebSocket signaling endpoint (`wss://LIVEKIT_DOMAIN`) is routed through Traefik as normal.
See `docs/multinode.md` for multi-node deployment considerations.
<!-- metadata -->
* **Category**: Apps
* **Status**: 2, beta
* **Image**: [`lasuite/meet-backend`](https://hub.docker.com/r/lasuite/meet-backend), 4, upstream
* **Healthcheck**: Yes
* **Backups**: Yes
* **Email**: 3
* **Tests**: 2
* **SSO**: Yes
<!-- endmetadata -->

14
abra-entrypoint.sh Normal file
View File

@ -0,0 +1,14 @@
#!/bin/sh
set -e
[ -f /run/secrets/postgres_p ] && export DB_PASSWORD="$(cat /run/secrets/postgres_p)"
[ -f /run/secrets/django_sk ] && export DJANGO_SECRET_KEY="$(cat /run/secrets/django_sk)"
[ -f /run/secrets/django_sp ] && export DJANGO_SUPERUSER_PASSWORD="$(cat /run/secrets/django_sp)"
[ -f /run/secrets/oidc_rpcs ] && export OIDC_RP_CLIENT_SECRET="$(cat /run/secrets/oidc_rpcs)"
[ -f /run/secrets/livekit_as ] && export LIVEKIT_API_SECRET="$(cat /run/secrets/livekit_as)"
[ -f /run/secrets/email_pass ] && export DJANGO_EMAIL_HOST_PASSWORD="$(cat /run/secrets/email_pass)"
# if not in "env" mode, then execute the original entrypoint and command
if [ ! "$1" = "-e" ]; then
exec "$@"
fi

16
abra.sh Normal file
View File

@ -0,0 +1,16 @@
# Set any config versions here
# Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs
export ABRA_ENTRYPOINT_VERSION=v1
export NGINX_CONF_VERSION=v1
export PG_BACKUP_VERSION=v1
export MIGRATE_VERSION=v1
export LIVEKIT_CONFIG_VERSION=v1
environment() {
# this exports all the secrets as environment variables
source /abra-entrypoint.sh -e
}
migrate() {
/migrate.sh
}

287
compose.yml Normal file
View File

@ -0,0 +1,287 @@
# NOTE: based on https://github.com/suitenumerique/meet/blob/main/docs/installation/compose.md
# and https://github.com/suitenumerique/meet/blob/main/docs/examples/compose/compose.yaml
x-common-env: &common-env
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: "*"
# DJANGO_SECRET_KEY supplied via secrets
DJANGO_SETTINGS_MODULE: meet.settings
# DJANGO_SUPERUSER_PASSWORD supplied via secrets
# Logging
LOGGING_LEVEL_HANDLERS_CONSOLE:
LOGGING_LEVEL_LOGGERS_ROOT:
LOGGING_LEVEL_LOGGERS_APP:
# Python
PYTHONPATH: /app
# Mail
DJANGO_EMAIL_BRAND_NAME:
DJANGO_EMAIL_HOST:
DJANGO_EMAIL_LOGO_IMG:
DJANGO_EMAIL_PORT:
DJANGO_EMAIL_HOST_USER:
# DJANGO_EMAIL_HOST_PASSWORD supplied via secret
DJANGO_EMAIL_USE_SSL:
DJANGO_EMAIL_USE_TLS:
DJANGO_EMAIL_FROM:
# Backend URL
MEET_BASE_URL: "https://${DOMAIN}"
# Redis
REDIS_URL: "redis://${STACK_NAME}_redis:6379/0"
# OIDC - settings from .env, see .env.sample
OIDC_OP_JWKS_ENDPOINT:
OIDC_OP_AUTHORIZATION_ENDPOINT:
OIDC_OP_TOKEN_ENDPOINT:
OIDC_OP_USER_ENDPOINT:
OIDC_OP_LOGOUT_ENDPOINT:
OIDC_RP_CLIENT_ID:
# OIDC_RP_CLIENT_SECRET supplied via secrets
OIDC_RP_SIGN_ALGO:
OIDC_RP_SCOPES:
LOGIN_REDIRECT_URL:
LOGIN_REDIRECT_URL_FAILURE:
LOGOUT_REDIRECT_URL:
OIDC_REDIRECT_ALLOWED_HOSTS:
OIDC_AUTH_REQUEST_EXTRA_PARAMS:
# LiveKit
LIVEKIT_API_KEY:
LIVEKIT_API_URL: "https://${LIVEKIT_DOMAIN}"
# LIVEKIT_API_SECRET supplied via secrets
ALLOW_UNREGISTERED_ROOMS:
x-postgres-env: &postgres-env
# Postgresql db container configuration
POSTGRES_DB: meet
POSTGRES_USER: meet
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_p
# App database configuration
DB_HOST: db
DB_NAME: meet
DB_USER: meet
DB_PORT: 5432
# DB_PASSWORD supplied via secrets (this is same as POSTGRES_PASSWORD)
services:
app:
image: lasuite/meet-frontend:v1.8.0
networks:
- backend
deploy:
labels:
- "traefik.enable=false"
- "coop-cloud.${STACK_NAME}.timeout=${TIMEOUT:-120}"
- "coop-cloud.${STACK_NAME}.version=0.1.0+1.8.0"
user: "${DOCKER_USER:-1000}"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
environment:
- MEET_HOST=${DOMAIN}
- LIVEKIT_HOST=${LIVEKIT_DOMAIN}
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080 || exit 1"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
backend:
image: lasuite/meet-backend:v1.8.0
networks:
- backend
environment:
<<: [*common-env, *postgres-env]
AUTO_MIGRATIONS: "${AUTO_MIGRATIONS:-true}"
healthcheck:
test: ["CMD", "/abra-entrypoint.sh", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
user: "${DOCKER_USER:-1000}"
entrypoint: >
sh -c "if [ \"$$AUTO_MIGRATIONS\" = \"true\" ]; then /migrate.sh; fi && exec /abra-entrypoint.sh \"$$@\"" --
command: ["gunicorn", "-c", "/usr/local/etc/gunicorn/meet.py", "meet.wsgi:application"]
configs:
- source: abra_entrypoint
target: /abra-entrypoint.sh
mode: 0555
- source: migrate
target: /migrate.sh
mode: 0555
secrets:
- django_sk
- django_sp
- oidc_rpcs
- postgres_p
- livekit_as
- email_pass
celery:
image: lasuite/meet-backend:v1.8.0
networks:
- backend
healthcheck:
test: ["CMD", "celery", "-A", "meet.celery_app", "inspect", "ping", "--timeout", "5"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
user: "${DOCKER_USER:-1000}"
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
environment:
<<: [*common-env, *postgres-env]
entrypoint: ["/abra-entrypoint.sh"]
configs:
- source: abra_entrypoint
target: /abra-entrypoint.sh
mode: 0555
secrets:
- django_sk
- django_sp
- oidc_rpcs
- postgres_p
- livekit_as
- email_pass
db:
image: pgautoupgrade/pgautoupgrade:18-debian
networks:
- backend
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "meet", "-d", "meet"]
interval: 1s
timeout: 2s
retries: 300
environment:
<<: *postgres-env
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres:/var/lib/postgresql/data/pgdata
deploy:
labels:
backupbot.backup: "${ENABLE_BACKUPS:-true}"
backupbot.backup.pre-hook: "/pg_backup.sh backup"
backupbot.backup.volumes.postgres.path: "backup.sql"
backupbot.restore.post-hook: '/pg_backup.sh restore'
configs:
- source: pg_backup
target: /pg_backup.sh
mode: 0555
secrets:
- postgres_p
redis:
image: redis:8
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 15s
timeout: 5s
retries: 3
networks:
- backend
livekit:
image: livekit/livekit-server:v1.8.2
command: --config /livekit-server.yaml
# WebRTC ICE ports must be published directly on the host.
# These carry raw RTP media, not HTTP — cannot be proxied through Traefik.
# See docs/multinode.md for multi-node deployment considerations.
ports:
- target: 7881
published: 7881
protocol: tcp
mode: host
- target: 7882
published: 7882
protocol: udp
mode: host
configs:
- source: livekit_config
target: /livekit-server.yaml
networks:
- proxy
- backend
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.${STACK_NAME}_livekit.rule=Host(`${LIVEKIT_DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}_livekit.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}_livekit.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.services.${STACK_NAME}_livekit.loadbalancer.server.port=7880"
web:
image: nginx:1.29
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
configs:
- source: nginx_conf
target: /etc/nginx/conf.d/default.conf
networks:
proxy:
backend:
depends_on:
- backend
- app
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.${STACK_NAME}.tls=true"
- "traefik.http.services.${STACK_NAME}.loadbalancer.server.port=8083"
- "traefik.http.routers.${STACK_NAME}.rule=Host(`${DOMAIN}`${EXTRA_DOMAINS})"
- "traefik.http.routers.${STACK_NAME}.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.routers.${STACK_NAME}.entrypoints=web-secure"
networks:
proxy:
external: true
backend:
volumes:
postgres:
configs:
nginx_conf:
name: ${STACK_NAME}_nginx_conf_${NGINX_CONF_VERSION}
file: nginx.conf.tmpl
template_driver: golang
pg_backup:
name: ${STACK_NAME}_pg_backup_${PG_BACKUP_VERSION}
file: pg_backup.sh
abra_entrypoint:
name: ${STACK_NAME}_entrypoint_${ABRA_ENTRYPOINT_VERSION}
file: abra-entrypoint.sh
migrate:
name: ${STACK_NAME}_migrate_${MIGRATE_VERSION}
file: migrate.sh
livekit_config:
name: ${STACK_NAME}_livekit_config_${LIVEKIT_CONFIG_VERSION}
file: livekit-server.yaml.tmpl
template_driver: golang
secrets:
django_sk:
external: true
name: ${STACK_NAME}_django_sk_${SECRET_DJANGO_SK_VERSION}
oidc_rpcs:
external: true
name: ${STACK_NAME}_oidc_rpcs_${SECRET_OIDC_RPCS_VERSION}
django_sp:
external: true
name: ${STACK_NAME}_django_sp_${SECRET_DJANGO_SP_VERSION}
postgres_p:
external: true
name: ${STACK_NAME}_postgres_p_${SECRET_POSTGRES_P_VERSION}
livekit_as:
external: true
name: ${STACK_NAME}_livekit_as_${SECRET_LIVEKIT_AS_VERSION}
email_pass:
external: true
name: ${STACK_NAME}_email_pass_${SECRET_EMAIL_PASS_VERSION}

85
docs/multinode.md Normal file
View File

@ -0,0 +1,85 @@
# Multi-node deployment
## Background
This recipe publishes WebRTC media ports (TCP 7881, UDP 7882) directly on the host using `mode: host` in Docker Swarm. This is necessary because these ports carry raw RTP media packets — they are not HTTP traffic and cannot be proxied through Traefik.
The WebRTC protocol requires these specific port numbers. LiveKit advertises them as ICE candidates to clients, so they cannot be remapped to different host ports. This means only one LiveKit instance can run per host, regardless of how the ports are exposed (the same constraint applies to email servers on port 25, for example).
## Single-node deployments
On a single-node swarm (the typical Co-op Cloud setup), this works without any additional configuration. The ports are published on the only available node, and DNS for `LIVEKIT_DOMAIN` points to that same node.
## Multi-node deployments
On a multi-node swarm, `mode: host` binds the port on whichever node the container is scheduled to. This creates two considerations:
1. **DNS must point to the correct node.** The `LIVEKIT_DOMAIN` DNS record must resolve to the IP of the node actually running the LiveKit container, not just any swarm node.
2. **Firewall rules must target the correct node.** Ports 7881/tcp and 7882/udp must be open on the node running LiveKit.
### Option A: Placement constraints (simplest)
Pin the LiveKit service to a specific node using a compose override:
```yaml
# compose.override.yml
services:
livekit:
deploy:
placement:
constraints:
- node.hostname == your-livekit-node
```
Then point `LIVEKIT_DOMAIN` DNS to that node's IP and open the firewall ports there.
### Option B: Traefik entrypoints (centralized port management)
Instead of publishing ports directly on the LiveKit service, route them through Traefik as entrypoints. This way all exposed ports are on the Traefik node(s).
1. Add a Traefik compose override (e.g. `compose.lasuite-meet.yml`) that publishes the ports on the Traefik service:
```yaml
services:
app:
ports:
- target: 7881
published: 7881
protocol: tcp
mode: host
- target: 7882
published: 7882
protocol: udp
mode: host
```
2. Add Traefik entrypoints in `traefik.yml.tmpl`:
```yaml
entryPoints:
livekit-tcp:
address: ":7881"
livekit-udp:
address: ":7882/udp"
```
3. In the lasuite-meet compose.yml, remove the `ports:` from the livekit service and add Traefik TCP/UDP router labels instead:
```yaml
services:
livekit:
# Remove ports: section
deploy:
labels:
# Keep existing HTTP labels for signaling...
# Add TCP router for ICE/TCP
- "traefik.tcp.routers.${STACK_NAME}_livekit_ice.rule=HostSNI(`*`)"
- "traefik.tcp.routers.${STACK_NAME}_livekit_ice.entrypoints=livekit-tcp"
- "traefik.tcp.services.${STACK_NAME}_livekit_ice.loadbalancer.server.port=7881"
# Add UDP router for ICE/UDP
- "traefik.udp.routers.${STACK_NAME}_livekit_media.entrypoints=livekit-udp"
- "traefik.udp.services.${STACK_NAME}_livekit_media.loadbalancer.server.port=7882"
```
This centralizes port management on Traefik but requires modifying the Traefik recipe configuration. The Nextcloud Talk HPB recipe uses this same pattern for TURN/STUN on port 3478.

13
livekit-server.yaml.tmpl Normal file
View File

@ -0,0 +1,13 @@
port: 7880
redis:
address: {{ env "STACK_NAME" }}_redis:6379
keys:
{{ env "LIVEKIT_API_KEY" }}: {{ secret "livekit_as" }}
rtc:
udp_port: 7882
tcp_port: 7881
use_external_ip: true
webhook:
api_key: {{ env "LIVEKIT_API_KEY" }}
urls:
- http://{{ env "STACK_NAME" }}_backend:8000/api/v1.0/rooms/webhooks-livekit/

26
migrate.sh Normal file
View File

@ -0,0 +1,26 @@
#!/bin/sh
set -e
# Load secrets into environment
source /abra-entrypoint.sh -e
# Wait for database to be ready (up to 30 seconds)
i=0
while ! python manage.py check --database default 2>/dev/null; do
i=$((i+1))
if [ "$i" -ge 30 ]; then
echo "migrate: timed out waiting for database" >&2
exit 1
fi
sleep 1
done
# Idempotent: skip if no pending migrations
if python manage.py migrate --check > /dev/null 2>&1; then
echo "migrate: no pending migrations, skipping"
exit 0
fi
echo "migrate: applying pending migrations..."
python manage.py migrate --noinput
echo "migrate: done"

50
nginx.conf.tmpl Normal file
View File

@ -0,0 +1,50 @@
upstream meet_backend {
server {{ env "STACK_NAME" }}_backend:8000 fail_timeout=0;
}
upstream meet_frontend {
server {{ env "STACK_NAME" }}_app:8080 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# Disables server version feedback on pages and in headers
server_tokens off;
location @proxy_to_meet_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_backend;
}
location @proxy_to_meet_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_frontend;
}
location / {
try_files $uri @proxy_to_meet_frontend;
}
location /api {
try_files $uri @proxy_to_meet_backend;
}
location /admin {
try_files $uri @proxy_to_meet_backend;
}
location /static {
try_files $uri @proxy_to_meet_backend;
}
}

34
pg_backup.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/bash
set -e
BACKUP_FILE='/var/lib/postgresql/data/pgdata/backup.sql'
function backup {
export PGPASSWORD=$(cat $POSTGRES_PASSWORD_FILE)
pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > $BACKUP_FILE
}
function restore {
cd /var/lib/postgresql/data/pgdata/
restore_config(){
# Restore allowed connections
cat pg_hba.conf.bak > pg_hba.conf
su postgres -c 'pg_ctl reload'
}
# Don't allow any other connections than local
cp pg_hba.conf pg_hba.conf.bak
echo "local all all trust" > pg_hba.conf
su postgres -c 'pg_ctl reload'
trap restore_config EXIT INT TERM
# Recreate Database
psql -U ${POSTGRES_USER} -d postgres -c "DROP DATABASE ${POSTGRES_DB} WITH (FORCE);"
createdb -U ${POSTGRES_USER} ${POSTGRES_DB}
psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -1 -f $BACKUP_FILE
trap - EXIT INT TERM
restore_config
}
$@