LibrePortal/containers/matrix/docker-compose.yml
librelad 4eb5fa6434 feat(matrix,stoat): run without a domain, on the LAN or a VPN
Both apps demanded a domain and Traefik. That was over-constrained: LibrePortal
ships WireGuard, Headscale and private ports, so LAN and VPN-only is a
first-class deployment here, and Rocket.Chat and Mattermost already prove chat
apps work fine on http://<lan-ip>:<port>.

The gate on Matrix rested on a mistake of mine: server_name being permanent.
server_name and public_baseurl are independent — the identity can be a domain
you own with no DNS behind it while clients reach the server on a LAN address,
so federation can be switched on later by adding DNS and TLS, with no rebuild
and no lost history. CFG_MATRIX_SERVER_NAME now exposes exactly that, and the
install warns when it falls back to the machine's IP.

What is genuinely lost without a domain is stated where it belongs, at install:
Matrix cannot federate and Element's mobile apps want HTTPS; Stoat cannot do
camera or microphone, because browsers gate getUserMedia on a secure context
and a VPN does not change that, the check being on the URL scheme.

Both now derive their URL from the port that was actually allocated. Since ports
are only assigned during compose-up, each writes a best guess before start and
corrects it afterwards, restarting only when the value really changed.

Three bugs found while proving it works end to end:

- The Synapse image writes /data as its UID/GID env, default 991, which under
  rootless is a host sub-UID owning nothing — so the generated signing key could
  not be moved by the install user. Both the generate container and the service
  now run as the same identity USER_TAG resolves to.

- Element's config.json is bind-mounted as a file, and docker silently creates a
  DIRECTORY when the source is missing. An early return left exactly that
  landmine, which then broke every later run. It is written first now, and a
  stale directory is cleared.

- A successful admin registration was reported as an error: checkSuccess read $?
  after an intervening [[ ]] test rather than the command's own status.

Verified with no domain and no Traefik installed: Synapse answers
/_matrix/client/versions and /health on http://<ip>:<port>, admin login returns
a token, and Element is configured against the corrected base_url.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:51:16 +01:00

126 lines
7.2 KiB
YAML

networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
# Synapse — the homeserver. Everything that matters lives in
# ./data/homeserver.yaml, written by matrix_install_post_compose from
# resources/homeserver.yaml; Synapse takes no meaningful configuration from
# the environment, so there is little to see here.
matrix-synapse: #LIBREPORTAL|SERVICE_TAG_1|matrix-synapse
container_name: matrix-synapse
image: matrixdotorg/synapse:v1.158.0 #LIBREPORTAL|MATRIX_VERSION_TAG|v1.158.0
# Synapse writes the media store, and under rootless Docker the image's
# own uid maps to a host sub-UID that owns nothing. Same fix as the
# other apps: run as whoever owns the bind mounts.
user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA
restart: unless-stopped
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
# GLUETUN_OFF_END
volumes:
- ./data:/data
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
# These are not redundant with `user:` above. The image's entrypoint
# checks whether it is root and, if so, su-execs down to UID:GID —
# which default to 991:991. So a container started as 0:0 still ends
# up running as 991, and 991 maps to a host sub-UID that does not own
# the bind mount: Synapse then dies reading its own chmod-600
# homeserver.yaml. Pinning them to the same identity as USER_TAG
# stops the entrypoint dropping to a user that cannot read anything.
- UID=MATRIX_RUN_UID_DATA #LIBREPORTAL|MATRIX_RUN_UID_TAG|MATRIX_RUN_UID_DATA
- GID=MATRIX_RUN_GID_DATA #LIBREPORTAL|MATRIX_RUN_GID_TAG|MATRIX_RUN_GID_DATA
depends_on:
- matrix-postgres
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
libreportal.backup.db: "postgres:matrix-postgres:postgres:"
# The media store holds every uploaded file and avatar. It is not in
# the database, so without this it would not come back on restore.
libreportal.backup.files: "matrix-synapse:/data/media_store:data/media_store"
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.matrix-synapse.entrypoints: web,websecure
traefik.http.routers.matrix-synapse.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.matrix-synapse.tls: true
traefik.http.routers.matrix-synapse.tls.certresolver: production
traefik.http.services.matrix-synapse.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.matrix-synapse.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_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
# Element web — a static single-page app served by nginx. It talks to
# Synapse from the user's browser, not server-side, so it needs no link to
# the homeserver container beyond the base_url baked into config.json.
matrix-element: #LIBREPORTAL|SERVICE_TAG_2|matrix-element
container_name: matrix-element
image: vectorim/element-web:v1.12.25 #LIBREPORTAL|MATRIX_ELEMENT_VERSION_TAG|v1.12.25
restart: unless-stopped
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2
# GLUETUN_OFF_END
volumes:
- ./element/config.json:/app/config.json:ro
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_2_BEGIN
traefik.http.routers.matrix-element.entrypoints: web,websecure
traefik.http.routers.matrix-element.rule: Host(`DOMAINSUBNAME_DATA_2`) #LIBREPORTAL|DOMAINSUBNAME_TAG_2|DOMAINSUBNAME_DATA_2
traefik.http.routers.matrix-element.tls: true
traefik.http.routers.matrix-element.tls.certresolver: production
traefik.http.services.matrix-element.loadbalancer.server.port: PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2
traefik.http.routers.matrix-element.middlewares: MIDDLEWARE_DATA_2 #LIBREPORTAL|MIDDLEWARE_TAG_2|MIDDLEWARE_DATA_2
# TRAEFIK_PORT_2_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_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
# No `user:` override — the postgres entrypoint starts as root, chowns
# PGDATA and drops privileges, which works under rootless because
# container-root is the install user that owns the mount.
matrix-postgres: #LIBREPORTAL|SERVICE_TAG_3|matrix-postgres
image: postgres:15-alpine
container_name: matrix-postgres
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=MATRIX_DB_PASSWORD_1_DATA #LIBREPORTAL|MATRIX_DB_PASSWORD_1_TAG|MATRIX_DB_PASSWORD_1_DATA
- POSTGRES_DB=synapse
# Not optional. Synapse refuses to start against a database with any
# other collation or ctype — it needs deterministic byte ordering for
# its indexes, and a C.UTF-8 locale is the only thing that gives it.
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3