Привет. Мне стало интересно, насколько реально одному разработчику собрать продакшн‑похожую инфраструктуру мессенджера, если не опираться на managed‑решения и «волшебные» облачные компоненты. Не убийцу Telegram и не стартап на миллион пользователей, а именно инженерный эксперимент, который можно потрогать руками: развернуть стек, заставить его жить, увидеть его слабые места и понять, что в этой системе на самом деле является критичным.
Я заранее понимал, что на Хабре уже есть статьи вида: поставил Synapse + Element, вот конфиг Nginx, и это даже хорошо, но моя цель чуть другая: я не хочу пересказывать установку Synapse как таковую, я хочу показать, как выглядит сборка, когда к базовому Matrix‑серверу добавляется внешний слой идентификации (OIDC), VoIP‑часть (LiveKit + TURN) и механизм обновления Android‑клиента, где обновления не «просто скачались», а действительно проходят проверку подписи.
Чтобы было понятно, о чём вообще речь, я начну с постановки требований и общей архитектуры, а дальше постепенно уйду в конкретику конфигов. В следующих частях (если серия зайдёт) я отдельно разберу грабли, потому что они тут — не побочный эффект, а половина реального опыта.
Постановка задачи
Я сформулировал для себя набор требований:
собственный homeserver
внешняя авторизация через OIDC
поддержка E2EE (на стороне Matrix)
звонки через WebRTC
собственная селфхост инфраструктура
Android-клиент
безопасная система обновлений приложения
Что важно, мне нужны не SaaS решения, а "собственное производство" на личном сервере.
Архитектурно

Схематично система выглядит так (на схеме выше упрощённая модель взаимодействия компонентов).
Сервер - Ubuntu 22.04 LTS.
Оркестрация через Docker Compose: без Kubernetes и избыточной сложности, потому что цель - контролируемая production-подобная среда, а не распределённый кластер.
База данных - PostgreSQL, так как Matrix активно работает с событиями и состоянием комнат, и предсказуемая транзакционная модель здесь критична.
Дальше - разделение ответственности. MAS (Matrix Authentication Service) выступает OIDC-провайдером и отвечает за identity-слой: он выпускает и валидирует токены. Synapse занимается событиями, синхронизацией и E2EE на уровне протокола. LiveKit отвечает за WebRTC и медиатрафик, а Coturn обеспечивает TURN для работы через NAT, особенно в мобильных сетях.
Это не "Synapse в одиночку", а сборка независимых подсистем. Identity, messaging, VoIP и обновления клиента разделены логически и технически. Такой подход немного сложнее в настройке, но позволяет понимать, где именно возникает проблема, и не смешивать разные уровни системы в одном процессе.
Почему Matrix?
Писать сервер мессенджера с нуля дорого и по времени, и по рискам. Доставку событий, синхронизацию устройств, обработку состояний комнат и вообще всю математику вокруг этого большинство людей сильно недооценивают. А когда туда добавляется шифрование, федерация и работа на нестабильных сетях, становится понятно, что "написать свой сервер" - это почти отдельная профессия.
Matrix мне понравился тем, что он даёт готовую модель событий и E2EE, причём это не экспериментальная крипта, а давно реализованный стек (Olm/Megolm) с понятной логикой. Я не утверждаю, что всё идеально и что Matrix никогда не бывает сложным или тяжёлым, но с инженерной точки зрения это хороший фундамент. Дальше уже вопрос настройки и дисциплины: какие части вы берёте, какие выносите, где ставите границы доверия и что считаете "продакшн-подобным".
Docker Compose и базовая организация
Я не делал ничего экзотического по оркестрации. Для одной машины Docker Compose нормальный компромисс между простотой и управляемостью. Ниже фрагмент, чтобы было видно общую структуру (в реальном проекте, конечно, больше переменных окружения, volume'ов и сетей):
docker-compose.yml
version: "3.9" services: postgres: image: postgres:15 environment: POSTGRES_DB: synapse POSTGRES_USER: synapse POSTGRES_PASSWORD: strongpassword volumes: - pgdata:/var/lib/postgresql/data synapse: image: matrixdotorg/synapse:latest volumes: - ./synapse/data:/data depends_on: - postgres mas: image: ghcr.io/matrix-org/matrix-authentication-service:latest volumes: - ./mas/config.yaml:/config.yaml livekit: image: livekit/livekit-server:latest command: --config /config.yaml volumes: - ./livekit/config.yaml:/config.yaml coturn: image: coturn/coturn command: -n --log-file=stdout volumes: - ./turnserver.conf:/etc/coturn/turnserver.conf volumes: pgdata:
Да, это всё на одной машине, и да, это SPOF. Но в моём эксперименте это нормально: я хочу сначала понять систему и найти реальные узкие места, а не сразу распыляться на высокий уровень отказоустойчивости. Если бы я с первого дня строил HA-кластер, я бы в итоге не понял, что ломается из-за конфигов, а что ломается из-за самой распределённости.
Конфигурация Synapse: база и важные места
В homeserver.yaml для меня ключевыми были несколько блоков. Во-первых, корректный public_baseurl, чтобы клиенты не начинали жить своей жизнью. Во-вторых, listeners с x_forwarded: true, потому что впереди Nginx и корректная обработка заголовков критична. В-третьих, база данных (PostgreSQL). В-четвёртых, медиа-хранилище, потому что это быстро становится источником боли. И, наконец, OIDC-провайдер. Пример ключевых параметров:
homeserver.yaml
server_name: matrix.example.com public_baseurl: https://matrix.example.com/ listeners: - port: 8008 tls: false type: http x_forwarded: true resources: - names: [client, federation] database: name: psycopg2 args: user: synapse password: strongpassword database: synapse host: postgres cp_min: 5 cp_max: 10 media_store_path: /data/media_store max_upload_size: 50M
Тут, кажется, ничего "вау", но практика такая: если public_baseurl или x_forwarded настроены криво, вы будете ловить странные симптомы на клиентах, и они часто выглядят как "что-то с сетью", хотя это просто конфигурация.
Почему я вынес учётные записи во внешний слой и зачем OIDC
Для Matrix стандартная схема - локальные учётные записи, где Synapse хранит пароль и сам выдаёт access token. Это рабочий вариант, но он жёстко связывает identity и messaging в одном процессе. Мне хотелось архитектурного разделения: Synapse отвечает за события и комнаты, а identity за подтверждение личности и выпуск токенов.
Когда аутентификация вынесена во внешний OIDC-провайдер, появляются конкретные технические преимущества, а не просто "более красивая архитектура". Во-первых, можно использовать короткоживущие access tokens и управлять временем жизни сессий централизованно. Это значит, что даже при утечке токена окно атаки ограничено временем его действия, а обновление сессии идёт через refresh-механизм, который тоже контролируется провайдером.
Во-вторых, появляется нормальная key rotation через JWKS. OIDC-провайдер публикует набор публичных ключей, и при смене ключа достаточно обновить JWKS - Synapse автоматически начнёт проверять подписи новым ключом. Это даёт контролируемый процесс ротации ключей без перезапуска всей системы и без «перевыпуска» пользователей вручную.
В-третьих, централизованный revoke становится реальным инструментом. Если необходимо немедленно отозвать доступ (например, при подозрении на компрометацию), это делается на уровне identity-провайдера, и дальнейшая валидация токенов Synapse просто не пройдёт. Не нужно бегать по нескольким сервисам и синхронизировать их состояние.
Наконец, внешний identity-слой позволяет расширять аутентификацию без изменения Synapse. Например, можно добавить WebAuthn или TOTP, реализовать адаптивные политики (по IP, по устройству), внедрить дополнительные проверки риска. Messaging-слой при этом остаётся неизменным и продолжает доверять только валидному подписанному токену.
С инженерной точки зрения это не "модно", а удобно: identity становится отдельной подсистемой со своей логикой, своими ключами и своим жизненным циклом, а Synapse остаётся тем, чем должен быть - сервером событий.
homeserver.yaml
oidc_providers: - idp_id: mas idp_name: MAS issuer: "https://auth.example.com/" client_id: "synapse" client_secret: "secret" scopes: ["openid", "profile", "email"] user_mapping_provider: config: localpart_template: "{{ user.preferred_username }}"
На бумаге всё выглядит просто, но на практике OIDC очень любит точность. Ошибка в issuer, несовпадение redirect_uri, недоступный JWKS endpoint, разъехавшееся время на сервере или некорректный localpart_template, и вы получаете ситуацию, когда "вход не работает", а объяснения минимальны. Это тот случай, когда приходится дисциплинированно держать логи и проверять весь поток по шагам. Я отдельно разберу это в следующей части, потому что там было много ситуаций разряда "вроде всё правильно, но 401".
Nginx: проксирование Matrix API и well-known
Nginx здесь выполняет две роли: TLS termination и маршрутизацию запросов. Самая простая часть - проксировать /_matrix/ на Synapse:
matrix.example.com.conf
location /_matrix/ { proxy_pass http://synapse:8008; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; }
Но я довольно быстро понял, что без .well-known/matrix/client клиенты начинают вести себя так, будто вы где-то "не тот сервер" указали. Поэтому well-known у меня настроен явно. Это не магия, просто дисциплина: клиент должен легко находить base_url.
WebRTC-звонки: LiveKit + TURN
С VoIP я не хотел уходить в совсем глубокий Matrix-VoIP слой и решил использовать LiveKit как отдельную подсистему. Мне важно было получить управляемую серверную часть для звонков и возможность тонко настраивать порты и транспорт.
Конфиг LiveKit выглядит примерно так (смысл именно в rtc-части):
turnserver.yaml
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
external_ip: 203.0.113.10
Дальше идёт TURN. Coturn настроен на long-term credentials с shared secret, потому что это стандартный и понятный подход:
turn.conf
use-auth-secret
static-auth-secret=turnsharedsecret
realm=example.com
fingerprint
Проверяю это я обычно утилитой turnutils_uclient, потому что если TURN не работает, то потом вы будете дебажить "у меня звонки через Wi-Fi работают, а через LTE нет", и это будет очень долго и неприятно.
Медиа: простое локальное хранилище как первая итерация
Я оставил media_store_path локальным. Это осознанная простота: пока я не понял все взаимосвязи, я не хотел сразу добавлять S3-совместимое хранилище и размазывать проблему по нескольким компонентам. Поэтому медиа живут в volume и лежат в ожидаемой структуре local_content/....
Я понимаю ограничения такого подхода: это single point of failure, это не масштабируется как надо, и это плохо переживает рост. Но на первом этапе мне важнее было убедиться, что базовая схема работает и понятна, прежде чем переносить медиа куда-то ещё.
Подписанные обновления Android: чтобы обновление было доверенным
Отдельная тема - обновления. Я хотел, чтобы обновление APK было не просто «скачал с сервера», а чтобы клиент мог убедиться, что обновление выпущено мной, а не подменено по пути. Для этого я сделал простую схему: сервер отдаёт JSON с метаданными версии, а подпись делается Ed25519.
JSON выглядит примерно так:
metadata.json
{ "version": "1.2.0", "sha256": "HEX_HASH", "url": "https://example.com/app.apk", "timestamp": 1700000000, "signature": "BASE64_ED25519_SIGNATURE" }
Подписывается canonical string (фиксированный формат, чтобы исключить двусмысленность):
version\n timestamp\n sha256\n url
Ключи генерируются стандартно:
openssl genpkey -algorithm Ed25519 -out private.pem openssl pkey -in private.pem -pubout -out public.pem
Клиент сначала ��роверяет подпись и только потом скачивает и сверяет SHA-256. Мне нравится эта логика тем, что она понятная, проверяемая и не превращает обновление в "верьте на слово URL'у".
Итоги текущего этапа и ограничения
На этом этапе у меня есть работающий селфхост стек: Matrix-сервер с E2EE, внешняя аутентификация через OIDC, VoIP через LiveKit и TURN, плюс механизм обновления клиента с криптографической проверкой. Это уже достаточно "продакшн-похоже", чтобы начать ловить реальные проблемы, а не только "как поставить контейнеры".
При этом ограничения очевидны. Всё живёт на одной машине, масштабирования по воркерам синапс нет, Redis не подключён, полноценного мониторинга (Prometheus/Grafana) пока тоже нет. Это не попытка выдать лабораторный стенд за enterprise-систему. Это намеренно упрощённая платформа, чтобы понимать систему, а не утонуть в инфраструктурной сложности.
Что будет дальше
В следующих частях я хочу разобрать то, что на самом деле делает этот стек интересным: грабли и их лечение. Например, почему Synapse иногда отдаёт 401 без внятной диагностики в OIDC‑связке, как у меня вели себя медиа при ошибках конфигурации, и как отлавливать проблемы TURN/TLS, когда вроде всё поднято, но звонки не проходят в одной из сетей.
Если эта статья полезна, я продолжу серию уже не в режиме «вот как я собрал», а в режиме «вот где я страдал и вот что оказалось причиной».
UPD:
После публикации статьи я оставил стенд работать в обычном режиме и решил посмотреть на реальные метрики системы. Нагрузка пока небольшая, но даже на этом этапе уже появляются наблюдения, которые сложно увидеть на "чистом" развёртывании.
Сейчас всё работает на одной машине (Ubuntu 22.04, 4 CPU, 16 GB RAM). При небольшом количестве пользователей (10-15 человек онлайн одновременно) и комнат (2 комнаты) картина примерно такая:
- Synapse держится в районе 300-500 MB RAM
- PostgreSQL использует примерно 120-180 MB RAM
- MAS и Nginx почти незаметны по потреблению ресурсов
- LiveKit практически не нагружает CPU, пока нет активных звонков. Без активности занимает доли процента, при 1-3 одновременных звонках повышается примерно до 10-20% CPU
- TURN начинает потреблять заметный трафик в основном при работе клиентов через мобильные сети
Что оказалось неожиданным: Matrix довольно быстро накапливает данные. Даже при небольшом количестве сообщений база начинает расти быстрее, чем ожидаешь. Это не критично, но довольно быстро становится понятно, что мониторинг базы и резервное копирование - не опциональная часть системы.
Отдельно посмотрел на поведение медиа. При локальном media_store_path всё работает предсказуемо, но становится очевидно, что при росте количества пользователей перенос медиа в отдельное хранилище (например, S3-совместимое) почти неизбежен.
