From a99541d0fe9c227703448b4dd07198029a6dce93 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:10:41 +0400 Subject: [PATCH] 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 --- docs/CHAT-RELAY.md | 75 +++++++++++++++++++-- scripts/relay/.dockerignore | 4 ++ scripts/relay/.env.example | 21 ++++++ scripts/relay/Dockerfile | 70 ++++++++++++++++++++ scripts/relay/docker-compose.yaml | 87 +++++++++++++++++++++++++ scripts/relay/entrypoint.py | 105 ++++++++++++++++++++++++++++++ scripts/relay/sql_exporter.yml | 71 ++++++++++++++++++++ 7 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 scripts/relay/.dockerignore create mode 100644 scripts/relay/.env.example create mode 100644 scripts/relay/Dockerfile create mode 100644 scripts/relay/docker-compose.yaml create mode 100755 scripts/relay/entrypoint.py create mode 100644 scripts/relay/sql_exporter.yml diff --git a/docs/CHAT-RELAY.md b/docs/CHAT-RELAY.md index a06c06026f..8f94aa8090 100644 --- a/docs/CHAT-RELAY.md +++ b/docs/CHAT-RELAY.md @@ -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 ``` diff --git a/scripts/relay/.dockerignore b/scripts/relay/.dockerignore new file mode 100644 index 0000000000..00bc43ddaf --- /dev/null +++ b/scripts/relay/.dockerignore @@ -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 diff --git a/scripts/relay/.env.example b/scripts/relay/.env.example new file mode 100644 index 0000000000..7bfbd89615 --- /dev/null +++ b/scripts/relay/.env.example @@ -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://@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 diff --git a/scripts/relay/Dockerfile b/scripts/relay/Dockerfile new file mode 100644 index 0000000000..1a731adf41 --- /dev/null +++ b/scripts/relay/Dockerfile @@ -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"] diff --git a/scripts/relay/docker-compose.yaml b/scripts/relay/docker-compose.yaml new file mode 100644 index 0000000000..569d403966 --- /dev/null +++ b/scripts/relay/docker-compose.yaml @@ -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: diff --git a/scripts/relay/entrypoint.py b/scripts/relay/entrypoint.py new file mode 100755 index 0000000000..522e87eb22 --- /dev/null +++ b/scripts/relay/entrypoint.py @@ -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() diff --git a/scripts/relay/sql_exporter.yml b/scripts/relay/sql_exporter.yml new file mode 100644 index 0000000000..ee8f62218b --- /dev/null +++ b/scripts/relay/sql_exporter.yml @@ -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'