mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-26 15:59:48 +00:00
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:
+71
-4
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
Executable
+105
@@ -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()
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user