scripts: add chat relay docker setup (#7406)

* scripts: add chat relay docker setup

* docs: add docker setup to chat relay guide

* scripts: bump relay ref and add RTS options

* scripts: reduce comments in relay setup

* scripts: increase relay pool and queue size

* scripts: add relay metrics exporter

* docs: trim docker section in chat relay guide

* scripts: bump cabal for hackage key rotation

* scripts: retry cabal update on mirror failure

* scripts: split cabal update into its own layer

* scripts: allow setting relay address server

* docs: adjust systemd service limits

* docs: mention docker volume full path
This commit is contained in:
sh
2026-08-26 09:10:41 +01:00
committed by GitHub
parent 0210591af7
commit a99541d0fe
7 changed files with 429 additions and 4 deletions
+71 -4
View File
@@ -1,6 +1,6 @@
---
title: Hosting your own Chat Relay
revision: 16.07.2026
revision: 28.07.2026
---
# Hosting your own Chat Relay
@@ -20,6 +20,7 @@ This guide explains how to set up a chat relay on a Linux server, how to run it,
- [Relay options](#relay-options)
- [Get the relay address](#get-the-relay-address)
- [Run relay commands](#run-relay-commands)
- [Run with Docker](#run-with-docker)
- [Channel web previews](#channel-web-previews)
- [Relay web options](#relay-web-options)
- [Serve the previews with Caddy](#serve-the-previews-with-caddy)
@@ -126,6 +127,73 @@ simplex-chat-relay -d /home/relay/relay -e "/set profile image file /home/relay/
systemctl start simplex-relay
```
## Run with Docker
The relay can also be built and run with Docker Compose, using PostgreSQL for storage. The files are in [`scripts/relay`](https://github.com/simplex-chat/simplex-chat/tree/master/scripts/relay).
1. Clone the repository and switch to the relay directory:
```sh
git clone https://github.com/simplex-chat/simplex-chat
cd simplex-chat/scripts/relay
```
2. Copy the example environment file, then set `RELAY_NAME`, `RELAY_WEB_DOMAIN` and `POSTGRES_PASSWORD` in it:
```sh
cp .env.example .env
```
3. Create the directory for the previews and the CORS file, owned by the container's user (UID `1000`):
```sh
mkdir -p /var/www/relay-web-channels/channel
chown -R 1000:1000 /var/www/relay-web-channels
chmod 0755 /var/www/relay-web-channels
```
4. Create the output directory for the relay address, then build and start:
```sh
mkdir -p out && chown 1000:1000 out
docker compose build
docker compose up -d
```
The first build compiles from source and takes a while.
5. Read the relay address, written on the first start:
```sh
cat out/relay-address.txt
```
To give the relay a picture, put a small `.png`/`.jpg`/`.jpeg` file (large images are rejected) next to the compose file and add a `docker-compose.override.yml`:
```yaml
services:
relay:
environment:
RELAY_IMAGE_FILE: /avatar.png
volumes:
- ./avatar.png:/avatar.png:ro
```
To run a one-off command against the relay's database, override the entrypoint:
```sh
docker compose run --rm --entrypoint sh relay -c \
'simplex-chat-relay -d "$DB_CONN" -e "/set profile image file /avatar.png"'
```
Relay metrics from the database are published by [sql_exporter](https://github.com/burningalchemist/sql_exporter) on `127.0.0.1:9399/metrics`, with the queries in `sql_exporter.yml`.
Relay database live in PostgreSQL docker volume. To print the full path to PostgreSQL database, execute in the host:
```sh
docker volume inspect simplex-chat-relay_pgdata --format '{{.Mountpoint}}'
```
## Channel web previews
Chat relays can render recent messages of its public channels as JSON files, which can be served over HTTPS using a web server to create channel web previews. This is optional.
@@ -219,11 +287,9 @@ Create `/etc/systemd/system/simplex-cors-sync.service`:
```ini
[Unit]
Description=Sync SimpleX relay CORS config to Caddy
StartLimitIntervalSec=30
StartLimitBurst=10
StartLimitIntervalSec=0
[Service]
Type=oneshot
ExecStartPre=/bin/sleep 2
ExecStart=/usr/local/bin/simplex-cors-sync.sh
```
@@ -236,6 +302,7 @@ After=caddy.service
[Path]
PathChanged=/var/www/relay-web-channels/cors.conf
Unit=simplex-cors-sync.service
TriggerLimitIntervalSec=0
[Install]
WantedBy=multi-user.target
```
+4
View File
@@ -0,0 +1,4 @@
# The Dockerfile clones the source itself and only needs entrypoint.py from the
# build context. Exclude everything else, especially .env (secrets) and out/.
*
!entrypoint.py
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env and edit.
RELAY_NAME="My Relay"
RELAY_WEB_DOMAIN=relay1.example.com
POSTGRES_USER=simplex
POSTGRES_DB=simplex_chat_relay
POSTGRES_PASSWORD=change-me
# Ref to build: tag, branch or commit.
CHAT_REF=88df79d1e26921c7d1835fc8484fade596356fb6
# SMP server for the relay address, used when the address is created.
#RELAY_ADDRESS_SERVER=smp://<fingerprint>@smp.example.com
# GHC runtime options, without the +RTS/-RTS markers.
#RELAY_RTS_OPTS="-N -F1.2 -A16m -I0.01 -Iw15"
# Database connection pool and internal queue size.
#RELAY_POOL_SIZE=4
#RELAY_QUEUE_SIZE=65536
+70
View File
@@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1
ARG CHAT_REF=88df79d1e26921c7d1835fc8484fade596356fb6
ARG GHC=9.6.3
# 3.10.1.0 predates the Hackage root key rotation and fails `cabal update`.
ARG CABAL=3.10.2.0
# ---- build ----------------------------------------------------------------
FROM ubuntu:22.04 AS build
ARG GHC
ARG CABAL
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential \
libpq-dev libgmp3-dev zlib1g-dev libnuma-dev libssl-dev \
llvm-12 llvm-12-dev \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \
| BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
BOOTSTRAP_HASKELL_GHC_VERSION="${GHC}" \
BOOTSTRAP_HASKELL_CABAL_VERSION="${CABAL}" sh \
&& ghcup set ghc "${GHC}" \
&& ghcup set cabal "${CABAL}"
# Kept out of the layer above so that a failure here does not reinstall GHC.
# Retried because a failed fetch falls back to the mirrors in Hackage's
# mirrors.json, which are dead, and the last one aborts the build.
RUN cabal update || { sleep 5; cabal update; } || { sleep 20; cabal update; }
# Declared after the toolchain layers so that changing the ref does not rebuild
# them. Unlike `clone --branch`, this form also accepts a commit hash.
ARG CHAT_REF
WORKDIR /project
RUN git init -q . \
&& git remote add origin https://github.com/simplex-chat/simplex-chat \
&& git fetch -q --depth 1 origin "${CHAT_REF}" \
&& git checkout -q FETCH_HEAD
# The cache mounts keep compiled dependencies across ref changes.
RUN --mount=type=cache,target=/root/.cabal/store,sharing=locked \
--mount=type=cache,target=/project/dist-newstyle,sharing=locked \
cp scripts/cabal.project.local.linux cabal.project.local \
&& cabal build -fclient_postgres exe:simplex-chat \
&& bin=$(find dist-newstyle -name simplex-chat -type f -executable | head -n1) \
&& install -m 0755 "$bin" /simplex-chat-relay \
&& strip /simplex-chat-relay
# ---- runtime --------------------------------------------------------------
FROM debian:stable-slim AS runtime
# The binary is dynamically linked, so it needs these libraries at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates python3 \
libpq5 libgmp10 libssl3 zlib1g libnuma1 libffi8 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1000 relay
COPY --from=build /simplex-chat-relay /usr/local/bin/simplex-chat-relay
COPY entrypoint.py /usr/local/bin/entrypoint.py
# Bind-mounted host directories must be writable by this UID.
USER relay
# The relay shuts down cleanly on SIGINT, not SIGTERM.
STOPSIGNAL SIGINT
ENTRYPOINT ["python3", "/usr/local/bin/entrypoint.py"]
+87
View File
@@ -0,0 +1,87 @@
name: simplex-chat-relay
services:
relay:
build:
context: .
args:
CHAT_REF: ${CHAT_REF:-88df79d1e26921c7d1835fc8484fade596356fb6}
image: chat-relay:latest # set your image/registry name
container_name: chat-relay
depends_on:
db:
condition: service_healthy
environment:
RELAY_NAME: ${RELAY_NAME}
RELAY_WEB_DOMAIN: ${RELAY_WEB_DOMAIN}
RELAY_ADDRESS_SERVER: ${RELAY_ADDRESS_SERVER:-}
# Empty values fall back to the entrypoint defaults.
RELAY_RTS_OPTS: ${RELAY_RTS_OPTS:-}
RELAY_POOL_SIZE: ${RELAY_POOL_SIZE:-}
RELAY_QUEUE_SIZE: ${RELAY_QUEUE_SIZE:-}
# Password is passed as PGPASSWORD to keep it out of argv.
DB_CONN: postgresql://${POSTGRES_USER}@db:5432/${POSTGRES_DB}
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /var/www/relay-web-channels:/var/www/relay-web-channels
- ./out:/out
restart: unless-stopped
metrics:
image: burningalchemist/sql_exporter:latest
container_name: chat-relay-metrics
depends_on:
db:
condition: service_healthy
environment:
# Percent-encode the password if it contains URL-reserved characters.
SQLEXPORTER_TARGET_DSN: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
volumes:
- ./sql_exporter.yml:/etc/sql_exporter/sql_exporter.yml:ro
command: ["-config.file=/etc/sql_exporter/sql_exporter.yml"]
ports:
- "127.0.0.1:9399:9399"
restart: unless-stopped
db:
image: postgres:18
container_name: chat-relay-db
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
shm_size: 256mb # parallel workers need more than the 64 MB default
volumes:
# PG18+ keeps PGDATA in a version-specific subdirectory of this volume.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 10
# Tuning for ~5 GB RAM / 4 cores. Match to the actual server.
command:
- postgres
- --max_connections=100
- --shared_buffers=1280MB
- --effective_cache_size=3840MB
- --maintenance_work_mem=320MB
- --checkpoint_completion_target=0.9
- --wal_buffers=16MB
- --default_statistics_target=100
- --random_page_cost=1.1
- --effective_io_concurrency=200
- --work_mem=6301kB
- --huge_pages=off
- --jit=off
- --wal_compression=lz4
- --min_wal_size=1GB
- --max_wal_size=4GB
- --max_worker_processes=4
- --max_parallel_workers_per_gather=2
- --max_parallel_workers=4
- --max_parallel_maintenance_workers=2
restart: unless-stopped
volumes:
pgdata:
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Save the relay address on first start, then exec the relay.
The address is printed only when it is created, and is not stored in the
database in its displayed form, so it is captured from the relay's output.
"""
import os
import shlex
import subprocess
import sys
BIN = "simplex-chat-relay"
WEB_ROOT = "/var/www/relay-web-channels"
ADDR_FILE = "/out/relay-address.txt"
CAPTURE_TIMEOUT = 180 # seconds
DEFAULT_RTS_OPTS = "-N -F1.2 -A16m -I0.01 -Iw15"
DEFAULT_POOL_SIZE = "4" # the binary defaults to a single connection
DEFAULT_QUEUE_SIZE = "65536"
def require(name):
value = os.environ.get(name)
if not value:
sys.exit(f"{name} is required")
return value
def rts_args():
"""RELAY_RTS_OPTS holds bare options; the +RTS/-RTS markers are added here."""
opts = [
o
for o in shlex.split(os.environ.get("RELAY_RTS_OPTS") or DEFAULT_RTS_OPTS)
if o not in ("+RTS", "-RTS")
]
return ["+RTS", *opts, "-RTS"] if opts else []
def find_address(text):
for token in text.split():
if token.startswith("https://") or token.startswith("simplex:"):
return token
return None
def capture_address(oneshot):
"""Create the address if needed and save it. Runs before the relay starts."""
cmd = oneshot + ["--create-schema", "-t", "0", "-e", "/sa"]
try:
out = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
timeout=CAPTURE_TIMEOUT,
).stdout
except subprocess.TimeoutExpired as exc:
out = exc.stdout or ""
sys.stdout.write(out)
sys.stdout.flush()
address = find_address(out)
if address:
with open(ADDR_FILE, "w") as f:
f.write(address + "\n")
else:
sys.stderr.write("entrypoint: relay address not captured; will retry next start\n")
def main():
name = require("RELAY_NAME")
domain = require("RELAY_WEB_DOMAIN")
conn = require("DB_CONN")
image_file = os.environ.get("RELAY_IMAGE_FILE")
os.makedirs(f"{WEB_ROOT}/channel", exist_ok=True)
os.makedirs("/out", exist_ok=True)
common = [BIN, "--relay", "--headless", "--user-display-name", name]
# Only applied when the address is created, which the one-shot below does.
address_server = os.environ.get("RELAY_ADDRESS_SERVER")
if address_server:
common += ["--relay-address-server", address_server]
# The image is applied only when the profile is created.
if not os.path.exists(ADDR_FILE):
oneshot = common + (["--user-image-file", image_file] if image_file else []) + ["-d", conn]
capture_address(oneshot)
relay = common + [
"--relay-web-domain", domain,
"--relay-web-dir", f"{WEB_ROOT}/channel",
"--relay-web-cors-file", f"{WEB_ROOT}/cors.conf",
"--relay-web-interval", "30",
"-d", conn,
"--create-schema",
"--pool-size", os.environ.get("RELAY_POOL_SIZE") or DEFAULT_POOL_SIZE,
"--queue-size", os.environ.get("RELAY_QUEUE_SIZE") or DEFAULT_QUEUE_SIZE,
] + rts_args()
os.execvp(relay[0], relay)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
global:
min_interval: 0s
max_connections: 3
max_idle_connections: 3
target:
name: chat_relay
# Replaced by SQLEXPORTER_TARGET_DSN. A non-empty value is required here
# because the DSN is validated before environment variables are applied.
data_source_name: "postgresql://replaced-by-env"
collectors: [chat_relay]
collectors:
- collector_name: chat_relay
metrics:
- metric_name: chat_relay_channels
type: gauge
help: "Channels by relay status."
key_labels: [status]
values: [channels]
query: |
SELECT relay_own_status AS status, count(*) AS channels
FROM simplex_v1_chat_schema.groups
WHERE relay_own_status IS NOT NULL
GROUP BY relay_own_status
- metric_name: chat_relay_published_channels
type: gauge
help: "Served channels that have a public address."
values: [channels]
query: |
SELECT count(*) AS channels
FROM simplex_v1_chat_schema.groups g
JOIN simplex_v1_chat_schema.group_profiles gp
ON gp.group_profile_id = g.group_profile_id
WHERE gp.public_group_id IS NOT NULL
AND g.relay_own_status IN ('active', 'accepted')
- metric_name: chat_relay_members
type: gauge
help: "Members across all channels."
values: [members]
query: |
SELECT count(*) AS members FROM simplex_v1_chat_schema.group_members
- metric_name: chat_relay_pending_deliveries
type: gauge
help: "Messages queued for delivery."
values: [deliveries]
query: |
SELECT count(*) AS deliveries
FROM simplex_v1_chat_schema.msg_deliveries
WHERE delivery_status = 'snd_pending'
- metric_name: chat_relay_oldest_pending_delivery_seconds
type: gauge
help: "Age of the oldest message queued for delivery."
values: [age]
query: |
SELECT COALESCE(EXTRACT(EPOCH FROM (now() - min(created_at))), 0) AS age
FROM simplex_v1_chat_schema.msg_deliveries
WHERE delivery_status = 'snd_pending'
- metric_name: chat_relay_messages_24h
type: gauge
help: "Chat items created in the last 24 hours."
values: [messages]
query: |
SELECT count(*) AS messages
FROM simplex_v1_chat_schema.chat_items
WHERE created_at > now() - interval '24 hours'