Не шучу, правда довели. Одни уходят, другие замедляют. И у меня начала мелькать шальная мысль: а что, если взять и поднять свой корпоративный чат?

Но я это делаю вообще в первый раз, поэтому сразу появилась гора вопросов: а с чего начать, как это спроектировать, сколько нужно серверов, нужен ли VPN, как не оставить дыру в безопасности и не собрать систему, которую потом я сам же запарюсь поддерживать?

Я Марк Ковалев, технический специалист по ИБ. И сегодня я отвечу на все эти вопросы себе и вам, а также расскажу, как поднял корпоративный мессенджер в облаке с нуля. Сильно не ругайтесь, я знаю, что решение может быть не самым оптимальным — поэтому жду советы в комментах, чтобы его докрутить и сделать круче.

Что не так с телегой под VPN?

Даже если бы телегу не замедляли, с ней все равно много проблем. Расскажу про них поподробнее.

У нас небольшая команда разработки, до 15 человек. Два года мы жили в телеге, и раньше все было нормально: и быстро, и привычно. Но все это время команда росла и процессы тоже менялись. И я, и остальные стали замечать недостатки, которые полезли со всех сторон.

Боль №1: треды

В телеге ответ на сообщение — это цитирование в общий поток, а не ветка обсуждения. Когда в одном рабочем канале одновременно идут разговоры о баге в проде, пиаре на ревью и об организации корпоратива, навигация превращается в пытку. Да, есть топики в супергруппах, но, боже мой, как же это неудобно! Вы уже мучились в попытке выйти из супергруппы и скрыть меню всех подгрупп? Ну вот и я о том же, ненавижу супергруппы. Mattermost и Slack, к слову, решили эту задачу пять лет назад. А телега до сих пор нет.

Боль №2: пуши по CI/CD

Мы написали бота, который отправлял результаты билдов и деплоев в отдельный чат. Но гранулярности ноль: нельзя подписаться на уведомления от конкретного пайплайна или отфильтровать по проекту. Все валится в одну ленту, и через неделю люди просто мьютят чат. А иногда и раньше. Бот превращается в помойку, в которую никто не смотрит.

Боль №3: поиск по истории

Попробуйте найти обсуждение архитектурного решения полугодовой давности в телеграм-чате на 15 человек с несколькими тысячами сообщений в месяц. Телега в общем поиске ищет по подстроке, без фильтрации по автору, дате или каналу (внутри канала можно поискать по автору или дате, но не всегда выдает верные результаты). Вы получите 200 результатов и будете листать вручную. Но в процессе все может залагать — и придется листать заново.

Боль №4: права доступа

Точнее, их отсутствие. В Telegram нельзя дать стажеру доступ только к каналам его проекта. Он либо в чате, либо нет. Нет ролей — только косметические лейблы ролей/должностей, нет per-channel permissions, нет гостевых аккаунтов с ограниченным доступом.

Боль №5: конфиденциальность

Корпоративная переписка, внутренние API-ключи, проброшенные в сообщениях (да, так не надо, но так бывает), обсуждение инцидентов и постмортемов — все это живет на серверах публичного мессенджера. Серверная часть телеги — проприетарный код, условия доступа к данным определяет третья сторона. Для нас это перестало быть приемлемым.

По итогу наше решение: self-hosted-чат на собственной инфраструктуре, доступный исключительно через VPN. Ниже — то, как мы выбирали платформу, почему остановились на Matrix, и полная инструкция по развертыванию.

А кандидаты кто?

Мы рассматривали три платформы: Mattermost, Rocket.Chat и Matrix + Element. У каждой свои сильные стороны и своя галька в тапочке, которая к 2026 году стала мешаться еще сильнее, чем еще пару лет назад.

Mattermost: был фаворитом, пока не вышла v11

Mattermost долго был золотым стандартом self-hosted-чата для разработчиков. Интерфейс, скопированный со Slack, каналы, треды, вебхуки, интеграции с GitLab и Jenkins из коробки. Простой деплой тоже был плюсом: один docker-контейнер с PostgreSQL. Документация качественная, сообщество активное. Для небольшой команды это идеальный кандидат.

Все изменилось в октябре 2025 года с выходом v11.0. Mattermost радикально перекроил бесплатные предложения. Теперь при запуске enterprise-бинарника без лицензии вы получаете Entry Edition, которая накладывает лимит в 10 000 сообщений — не на канал, а на весь сервер суммарно. Старые сообщения остаются в базе данных, но полностью пропадают из интерфейса и поиска. Для команды, генерирующей 200–300 сообщений в день, это примерно полтора месяца до стены. Помимо этого, звонки ограничены 40 минутами, пуш-уведомления — 10 000 в месяц, доски — 1 000 карточками, плейбуки — пятью запусками в месяц.

Реакция сообщества была, мягко говоря, бурной. На Hacker News тред набрал 314 очков и более 250 комментариев. Пользователи сравнивали ретроактивное скрытие истории с ransomware. Администратор школы с 2 000 пользователей и 470 000 сообщений подал в GitHub issue #34271, после того как потерял доступ ко всей истории до сентября 2025 года.

В ответ на ситуацию появился форк Mostlymatter от французской некоммерческой организации Framasoft. Это drop-in-замена бинарника, убирающая лимиты на пользователей и сообщения. System76 перевела на него чат сообщества Pop!_OS в феврале 2026-го, YunoHost переключил свой официальный пакет. Для команд, которым нужен UX «Маттермоста» без искусственных ограничений, Mostlymatter — вполне рабочий вариант. Но зависимость от энтузиазма стороннего создателя, пусть и уважаемого, — не лучшая основа для долгосрочного планирования, на мой взгляд… Но тут сами решайте.

Еще один путь — остаться на v10.11 ESR, которая поддерживается до 15 августа 2026-го. Это покупает время, но не решает проблему.

Rocket.Chat: смерть от тысячи порезов

Rocket.Chat — вполне зрелый продукт с богатым интерфейсом: каналами, тредами, аудио- и видеозвонками, омниканальной поддержкой клиентов, маркетплейсом приложений. На нем сидят крупные игроки, в том числе некоторые юниты «Сбера». Но Community Edition обложена ограничениями, которые по отдельности кажутся терпимыми, а все вместе делают продукт непригодным для серьезного использования.

Главный лимит — 10 000 пуш-уведомлений в месяц через официальный шлюз. Эта квота была введена еще в 2020 году, и на форумах модераторы подтверждали в 2024 году, что пересматривать ее никто не планирует.

Обходной путь для пуш-уведомлений формально существует, но он дорогой. Официальная документация предлагает форкнуть мобильное приложение Rocket.Chat, создать собственные Firebase- и APNs-ключи, собрать white-label-приложение, опубликовать его в App Store и Google Play. Apple Developer Account стоит 99 долларов в год, плюс вы берете на себя постоянную поддержку сборки при каждом обновлении SDK. Для небольшой команды это какая-то жесть, а не решение.

Есть и бесплатный тариф Starter (до 50 пользователей, расширен с 25 в v7.0), который снимает лимит на пуши и добавляет премиум-функции. Но там нужна регистрация в Rocket.Chat Cloud и привязка сервера, что для self-hosted-сценария, мотивированного контролем над данными, выглядит как противоречие.

Matrix + Element: открытый протокол без лимитов

Matrix — открытый федеративный протокол для коммуникаций.

Synapse — эталонная серверная реализация на Python. 

Element — основной клиент, доступный как веб-приложение, десктопное приложение (Electron) и мобильные приложения на iOS и Android. 

Принципиальное отличие от «Маттермоста» и Rocket.Chat — в отсутствии лимитов на сообщения, пользователей, пуш-уведомления или функции. Протокол открыт (Apache 2.0), сервер — под Apache 2.0, клиент — тоже.

Федерация — ключевая особенность Matrix, но для внутреннего командного чата она не нужна. Если сервер доступен только через VPN и вы не планируете общаться с пользователями на других matrix-серверах, ее лучше отключить. 

Это делается одной строкой в homeserver.yaml

federation_domain_whitelist: []` // пустой список = федерация ни с кем 

Убирает фоновый трафик key-fetching и presence и уменьшает поверхность атаки.

Слабые стороны тоже есть. Развертывание заметно сложнее: Synapse + PostgreSQL + Element Web + Caddy + CoreDNS — это 5–8 контейнеров против 2–3 у «Маттермоста», да и начальная настройка занимает 2–4 часа вместо получаса. UX-онбординг тяжелее: верификация cross-signing при первом входе с нового устройства сбивает с толку нетехнических коллег, а потеря ключа восстановления равна потере зашифрованной истории навсегда.

Наш выбор

Да, по моему описанию было несложно понять, что мы выбрали Matrix + Element. Причина: это единственная платформа, где вендор не может задним числом ввести лимит на историю сообщений или количество пуш-уведомлений. Сложность развертывания для нас — это одноразовая цена. А вот лицензионные сюрпризы — штука повторяющаяся.

Важно! Если вашей команде не нужна федерация, а нужен максимально простой деплой с привычным Slack-like UX, присмотритесь к Mostlymatter. Он поднимается за час на том же стеке.

Архитектура решения

Итоговый стек развернут на одном виртуальном сервере в Сloud.ru. 

Конфигурация:

2 vCPU / 4 GB RAM (тип standard из линейки Evolution Compute), Debian 13, 40 GB.

Визуал решения вот такой:

Визуализация архитектуры
Визуализация архитектуры

Единственный порт, который открыт наружу, — UDP 51820 для WireGuard. Все остальные сервисы слушают только на VPN-интерфейсе (10.8.0.1). Веб UI wg-easy защищен паролем с двухфакторкой и доступен только через VPN. Caddy выступает в роли внутреннего удостоверяющего центра (CA) и генерирует и автоматически обновляет TLS-сертификаты для всех сервисов через директиву tls internal. Никаких внешних зависимостей, никаких публичных DNS-записей.

CoreDNS резолвит имена вида chat.team.internal и element.team.internal в VPN-адрес сервера. WireGuard-клиенты получают адрес DNS-сервера автоматически при подключении. Единственная ручная операция — один раз установить корневой сертификат Caddy на устройства участников команды.

Медиафайлы и вложения хранятся не на локальном диске, а в Evolution Object Storage — S3-совместимом хранилище. Оно работает со стандартным AWS SDK (boto3, aws-cli, rclone) через эндпойнт https://s3.cloud.ru, регион ru-1a, авторизация AWS Signature v4. Так я решил проблему роста диска VPS и упростил бэкапирование.

Развертывание

Шаг 1: подготовка VPS

В консоли Evolution создаем виртуальную машину. Если у вас совсем нет опыта или просто неохота разбираться, можно попросить их внутреннего ИИ-помощника сделать это за вас. 

Можно не самому, а через сценарии
Можно не самому, а через сценарии

Выбираем конфигурацию, которую описывал раньше: 2 vCPU / 4 GB RAM. Диск 40 GB SSD, операционная система Debian 12 (потом обновим до 13-й). Публичный IP делаем прямой на машину, без NATa.

Выбираем нужные опции
Выбираем нужные опции

На платформе есть free tier (2 vCPU, 4 GB, 30 GB), но без публичного IP. Добавление интернет-доступа стоит примерно 146 руб/мес. Но под мою задачу нужен полноценный публичный IP для WireGuard endpoint, поэтому free tier подходит только для тестирования. Новые аккаунты, кстати, получают 4 000 бонусных рублей на 60 дней, их тоже заюзал.

Добавляем свой SSH-ключ, подключаемся:

ssh user1@<PUBLIC_IP>

sudo apt update && sudo apt upgrade -y

Меняем bookworm на trixie и обновляемся до Debian 13:

sudo vi /etc/apt/sources.list
sudo apt update && sudo apt dist-upgrade -y

Меняем порт SSH на нестандартный, чтобы ботам хоть чуть-чуть сложнее было ддосить машину:

sudo vi /etc/ssh/sshd_config
---
Port 3422
---
sudo systemctl restart ssh

На этом моменте мы сможем заметить, что новые SSH-подключения на новый порт не идут. Это потому, что порты по дефолту закрыты.

Идем в группы безопасности, находим ту, в которой сидит интерфейс машины, меняем SSH-правило. И заодно добавляем проброс порта для WireGuard-трафика:

Так нам и не нужно ставить ufw 
Так нам и не нужно ставить ufw 

Далее доустанавливаем докер, добавляем своего пользователя в группу, все стандартно.

# Добавляем GPG-ключ для Docker:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Добавляем репозиторий:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

sudo adduser user1 docker

Шаг 2: S3-хранилище для медиафайлов

В консоли Evolution создаем бакет в Evolution Object Storage. Имя — matrix-media; регион — ru-central-1; класс хранения — стандартный:

Этот бакет для Synapse, для хранения загруженных медиафайлов (изображения, файлы, аватарки). Локальный диск VPS при этом используется только для метаданных и кеширования.

Для его подключения еще нужны будут ключи доступа. Чтобы их сделать, заходим в «Настройки пользователя» → «Ключи доступа» → «Создать ключ». Делаем описание, задаем время жизни. Обязательно копируем Key Secret (пароль): после закрытия окошка его не получить. Также запоминаем Key ID (логин).

Шаг 3: WireGuard через wg-easy

У wg-easy v15 была полная переработка проекта в мае 2025-го: сменилась лицензия на AGPL-3.0. Главное изменение: почти все переменные окружения вроде WG_HOST, WG_DEFAULT_ADDRESS, WG_DEFAULT_DNS, PASSWORD_HASH удалены. И конфигурация теперь происходит через интерактивный мастер настройки при первом запуске, а параметры хранятся в SQLite-базе внутри контейнера. Из переменных окружения остались только PORT (TCP-порт Web UI, по умолчанию 51821), HOST (bind-адрес, по умолчанию 0.0.0.0), INSECURE (разрешить HTTP без TLS, по умолчанию false) и DISABLE_IPV6.

mkdir -p ~/wireguard && cd ~/wireguard
vi ./docker_compose.yml
volumes:
  etc_wireguard:

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15
    container_name: wg-easy
    network_mode: host
    environment:
      # Без reverse proxy внутри VPN, разрешаем plain HTTP
      - INSECURE=true
      # Выключаем IPv6
      - DISABLE_IPV6=true
    volumes:
      - etc_wireguard:/etc/wireguard
      - /lib/modules:/lib/modules:ro
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    devices:
      - /dev/net/tun:/dev/net/tun

Важно! /lib/modules:/lib/modules:ro — новое обязательное требование на v15 для доступа к модулям ядра. Без него контейнер не стартанет. А именованный том etc_wireguard вместо bind mount — это рекомендация из официальной доки.

Также используем хостовую сеть, чтобы интерфейс wg0 с адресом 10.8.0.1 был недоступен для бинда сервисов без лишних изворотов с роутингом.

Добавляем конфиг sysctl для форвардинга:

sudo tee /etc/sysctl.d/90-wireguard.conf << 'EOF'
net.ipv4.ip_forward=1
net.ipv4.conf.all.src_valid_mark=1
EOF

Дальше:

docker compose up -d

Делаем SSH-туннель, чтобы безопасно подключиться к незащищенной веб-морде:

ssh -L 51821:127.0.0.1:51821 -p 3422 -N user1@<PUBLIC_IP>

Заходим в http://127.0.0.1:51821. При первом запуске wg-easy показывает мастер настройки, в котором задаем:

  • Учетную запись администратора.

  • Host — публичный IP сервера.

  • Port — 51820 (по умолчанию).

Дальше в настройках задаем:

  • Разрешенные IP‑адреса. 10.8.0.0/24 (только VPN-трафик, не весь интернет клиента).

  • DNS. Указываем IP нашего будущего CoreDNS — 10.8.0.1.

  • Устройство. Указываем сетевой интерфейс машины, у меня enp3s0.

Также меняем хуки в настройках. По сути, убираем NAT. Через VPN доступа в обычную сеть нет, он только для доступа к внутренним ресурсам и, может быть, к коллегам.

PostUp:

iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT

PostDown:

iptables -D FORWARD -i wg0 -o wg0 -j ACCEPT

Создаем конфигурации и тестим. Теперь веб-интерфейс доступен также через VPN: http://10.8.0.1:51821.

Важно! Для автоматизации (Ansible, скрипты) в v15 есть INIT-переменные: INIT_ENABLED=true, INIT_USERNAME, INIT_PASSWORD, INIT_HOST, INIT_PORT, INIT_DNS и т. д. Они отрабатывают только при первом запуске и игнорируются при последующих. После первичной настройки рекомендуется убрать их из docker-compose, чтобы пароль не светился в конфиге.

Шаг 4: внутренний DNS на CoreDNS

Нам нужно, чтобы имена вроде chat.team.internal резолвились в VPN-адрес сервера (10.8.0.1) для клиентов внутри VPN. Это берет на себя CoreDNS.

Важно! Не используйте TLD .local, так как он зарезервирован для mDNS и конфликтует с Bonjour на macOS и iOS. Хорошие варианты: .internal (зарезервирован IANA для приватного использования) или home.arpa.

Качаем и устанавливаем свежую версию:

cd /tmp

wget https://github.com/coredns/coredns/releases/download/v1.14.2/coredns_1.14.2_linux_amd64.tgz

tar xzf coredns_1.14.2_linux_amd64.tgz

sudo mv coredns /usr/local/bin/

sudo chmod +x /usr/local/bin/coredns

coredns --version

Создаем директорию для конфигов:

sudo mkdir -p /etc/coredns

Файл зоны db.team.internal — стандартный формат BIND zone file:

sudo tee /etc/coredns/db.team.internal << 'EOF'
; $ORIGIN задает суффикс по умолчанию для неполных имен в этом файле
$ORIGIN team.internal.
; SOA (Start of Authority) обязательная запись для любой DNS-зоны
; Формат: primary-ns admin-email (serial refresh retry expire minimum-ttl)
; admin-email: точка вместо @, т. е. admin.team.internal = admin@team.internal
@   IN SOA ns.team.internal. admin.team.internal. (
        2025010101  ; serial — увеличивайте при каждом изменении зоны
        3600        ; refresh — как часто secondary DNS проверяет обновления (нам неважно)
        600         ; retry — интервал повтора при неудаче
        86400       ; expire — через сколько secondary перестает отдавать зону
        60          ; minimum TTL — время жизни negative-кеша
    )
; NS указывает authoritative nameserver для зоны
@   IN NS  ns.team.internal.
; A-записи: имя = IPv4-адрес внутри VPN
; Все сервисы живут на одном VPS, поэтому все указывают на 10.8.0.1
ns      IN A 10.8.0.1
chat    IN A 10.8.0.1   ; Synapse homeserver
element IN A 10.8.0.1   ; Element Web frontend
wg      IN A 10.8.0.1   ; wg-easy Web UI
EOF

Конфиг CoreDNS Corefile — каждый блок в { } описывает зону и набор плагинов для нее:

sudo tee /etc/coredns/Corefile << 'EOF'

# Зона team.internal — наши внутренние сервисы

# CoreDNS будет отвечать на запросы к *.team.internal

. {

    # привязываемся на WireGuard-сервер

    bind 10.8.0.1

    # Зона team.internal — наши внутренние сервисы

    # file — плагин, загружающий зону из файла в формате BIND

    file /etc/coredns/db.team.internal team.internal

    # forward — проксирует запросы к upstream DNS-серверам

    # Здесь используем Google (8.8.8.8) и Cloudflare (1.1.1.1)

    # Можно заменить на любые другие, например Яндекс (77.88.8.8)

    forward . 8.8.8.8 1.1.1.1

    # cache — кеширует ответы от upstream на 30 секунд

    # Уменьшает задержку и нагрузку на upstream при повторных запросах

    cache 30

    # log — логирует все DNS-запросы к этой зоне в stdout

    # Полезно для отладки; в продакшене можно убрать

    log

    # Return errors for failed queries instead of silently dropping

    # Возвращать ошибки, а не втихую дропать

    errors

}

EOF

Создаем systemd-сервис:

sudo tee /etc/systemd/system/coredns.service << 'EOF'

[Unit]

Description=CoreDNS DNS Server

Documentation=https://coredns.io

After=network-online.target

# Запускаемся после докера, чтобы подняться после wg-easy и интерфейс wg0 уже был на месте

After=docker.service

Wants=network-online.target

[Service]

Type=simple

ExecStart=/usr/local/bin/coredns -conf /etc/coredns/Corefile

Restart=on-failure

RestartSec=5

# Разрешение на привязку к порту 53

AmbientCapabilities=CAP_NET_BIND_SERVICE

# Запускаем отдельным юзером

User=coredns

Group=coredns

[Install]

WantedBy=multi-user.target

EOF

Добавляем юзера и запускаем сервис:

sudo useradd -r -s /usr/sbin/nologin -d /nonexistent coredns

sudo systemctl daemon-reload

sudo systemctl enable --now coredns

sudo systemctl status coredns
Вот как выглядит в терминале
Вот как выглядит в терминале
Со стороны клиента тоже все работает
Со стороны клиента тоже все работает

Шаг 5: Synapse + PostgreSQL + Element Web

Создаем рабочую директорию и генерируем начальный конфиг Synapse:

mkdir -p ~/matrix && cd ~/matrix
docker run -it --rm \
  -v ./data:/data \
  -e SYNAPSE_SERVER_NAME=chat.team.internal \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse generate

Редактируем data/homeserver.yaml. Ключевые секции, которые нужно изменить:

server_name: "chat.team.internal"

database:
  name: psycopg2
  args:
    user: synapse
    password: "СМЕНИТЬ_НА_НАДЕЖНЫЙ_ПАРОЛЬ"
    database: synapse
    host: postgres
    cp_min: 5
    cp_max: 10

media_storage_providers:
  - module: s3_storage_provider.S3StorageProviderBackend
    store_local: true
    store_remote: true
    store_synchronous: true
    config:
      bucket: matrix-media
      endpoint_url: https://s3.cloud.ru
      region_name: ru-central-1
      access_key_id: "Key ID"
      secret_access_key: "Key Secret"

# Отключаем открытую регистрацию. Пользователей создаем вручную

# или позже включим регистрацию по токену.
enable_registration: false

# Отключаем федерацию — сервер только для нашей команды
federation_domain_whitelist: []

listeners:
  - port: 8008
    type: http
    tls: false
    x_forwarded: true
    resources:
      - names: [client]
        compress: false

# global_factor < 1.0 уменьшает размер всех кешей пропорционально.
# Для 15 пользователей 0.5 более чем достаточно, экономит около 200–300 MB RAM.
caches:
  global_factor: 0.5

# Дальше идут настройки секретных ключей и прочего, что сгенерилось автоматом, мы это не трогаем

Для S3 storage provider нужен пакет synapse-s3-storage-provider, которого нет в базовом образе. Собираем свой:

cat > Dockerfile.synapse << 'EOF'

FROM matrixdotorg/synapse:latest

RUN pip install --no-cache-dir synapse-s3-storage-provider

EOF

Позже стоит добавить крон джобу для подчистки данных, которые ушли в S3, чтобы не засорять VPS:

docker exec synapse s3_media_upload update /data 30d

Конфиг Element Web (в element-config.json):

"default_server_config": {
        "m.homeserver": {
            "base_url": "https://chat.team.internal",
            "server_name": "chat.team.internal"
        }
    },
    "disable_guests": true,
    "disable_3pid_login": true,

    "brand": "Team Chat",

    "default_theme": "light",

    "room_directory": {

        "servers": ["chat.team.internal"]

    }
}

Конфиг для TURN-сервера, чтобы нормально работали звонки:

mkdir -p ~/matrix/coturn
# Генерим секрет
openssl rand -hex 32

cat > ~/matrix/coturn/turnserver.conf << 'EOF'

# Слушаем на VPN-интерфейсе

listening-ip=10.8.0.1

# Порт TURN

listening-port=3478

# Диапазон портов для media relay

min-port=49152

max-port=49200

# Авторизация -- static secret, разделяемый с Synapse

use-auth-secret

static-auth-secret=<сгенеренный секрет>

# Realm -- любое имя, обычно совпадает с доменом

realm=chat.team.internal

# Логирование

log-file=stdout

no-cli

EOF

Докинем в homeserver.yaml:

turn_uris:

  - "turn:10.8.0.1:3478?transport=udp"

  - "turn:10.8.0.1:3478?transport=tcp"

turn_shared_secret: <тот же секрет>

turn_user_lifetime: 86400000

turn_allow_guests: false

docker-compose.yml для всего стека Matrix:

services:

  postgres:

    image: postgres

    container_name: matrix-postgres

    environment:

      POSTGRES_USER: synapse

      POSTGRES_PASSWORD: changeme

      POSTGRES_DB: synapse

      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"

    volumes:

      - postgres-data:/var/lib/postgresql

    restart: unless-stopped

  synapse:

    build:

      context: .

      dockerfile: Dockerfile.synapse

    container_name: synapse

    depends_on:

      - postgres

    volumes:

      - ./data:/data

    restart: unless-stopped

  element-web:

    image: vectorim/element-web

    container_name: element-web

    volumes:

      - ./element-config.json:/app/config.json:ro

    restart: unless-stopped

  coturn:

    image: coturn/coturn:alpine

    container_name: coturn

    network_mode: host

    volumes:

      - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro

    restart: unless-stopped

volumes:

  postgres-data:

Шаг 6: Caddy с внутренним CA

Наши сервисы живут за VPN и не доступны из интернета, поэтому получить сертификат от Let's Encrypt через стандартный HTTP-01 challenge невозможно. Можно было бы использовать DNS-01 challenge (создавать TXT-записи через API DNS-провайдера), но это добавляет внешнюю зависимость и заметно усложняет настройку. Пока обойдемся без этого.

Проще так: Caddy умеет работать как собственный удостоверяющий центр (CA). Директива tls internal заставляет Caddy сгенерировать корневой сертификат (срок жизни — 10 лет), промежуточный сертификат и leaf-сертификаты для каждого сайта автоматически, без внешних запросов. Обновление leaf-сертификатов происходит прозрачно само. Единственное, нужно один раз установить корневой сертификат Caddy на устройства участников команды.

Caddyfile:

{
    # Все сайты по умолчанию используют внутренний CA
    local_certs

    # Опционально: задаем имя CA (видно в системном хранилище сертификатов)
    pki {
        ca local {
            name         "Team Internal CA"
            root_cn      "Team Internal Root CA"
        }
    }
}

# Synapse API
chat.team.internal {
    reverse_proxy synapse:8008
}

# Element Web
element.team.internal {
    reverse_proxy element-web:80
}

Добавляем Caddy в docker-compose стека Matrix (стандартный образ, без кастомной сборки, tls internal работает из коробки):

 caddy:
    image: caddy:2-alpine
    container_name: caddy
    ports:
      # Слушаем только на VPN-интерфейсе
      - "10.8.0.1:443:443"
      - "10.8.0.1:80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    restart: unless-stopped

volumes:
  caddy-data:
  caddy-config:

Порты привязаны к 10.8.0.1, из интернета к ним доступа нет. Кастомная сборка Caddy не нужна: tls internal — встроенная функция.

После первого запуска Caddy извлекаем корневой сертификат CA:

docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./team-root-ca.crt

Этот файл team-root-ca.crt нужно один раз установить на каждое устройство в команде. Сертификат действует 10 лет, так что повторная установка не потребуется. Шутить про Третью мировую не буду, сами дошутите.

# Linux (Ubuntu/Debian)
sudo cp team-root-ca.crt /usr/local/share/ca-certificates/team-root-ca.crt
sudo update-ca-certificates

# macOS
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain team-root-ca.crt

# Windows (PowerShell от имени администратора)
Import-Certificate -FilePath team-root-ca.crt -CertStoreLocation Cert:\LocalMachine\Root

На iOS: отправляем файл через AirDrop или скачиваем через VPN, устанавливаем профиль в «Настройки» → «Основные» → «VPN и управление устройством», затем включаем доверие в «Настройки» → «Основные» → «Об этом устройстве» → «Доверие сертификатам».

На Android: «Настройки» → «Безопасность» → «Установить сертификат» → «Сертификат ЦС». Система покажет предупреждение: «Сеть может отслеживаться». Это ок при установке любого стороннего CA.

Для небольшой команды это одноразовая операция, которую удобно описать в той же инструкции, что и подключение к VPN. Я добавил team-root-ca.crt в общую matrix-комнату с закрепленным сообщением.

Шаг 7: первый запуск и создание пользователей

cd ~/matrix
docker compose up -d

# Ждем инициализации (~ 30 сек.), затем создаем администратора
docker exec -it synapse register_new_matrix_user \
  -c /data/homeserver.yaml \
  -u admin -a \
  http://localhost:8008

Программа спросит пароль интерактивно.

Подключаемся к VPN и наконец заходим на https://element.team.internal. И радуемся, что работает!

Входим как admin. Создаем Space для команды, а внутри него — комнаты по проектам.

Для приглашения остальных участников включаем регистрацию по токену:

# homeserver.yaml
enable_registration: true
registration_requires_token: true

Перезапускаем Synapse:

docker compose restart synapse

В настройках находим и запоминаем токен доступа:

Делаем запрос на регистрационный токен:

curl -X POST https://chat.team.internal/_synapse/admin/v1/registration_tokens/new \

  -H "Authorization: Bearer ВАШ_ACCESS_TOKEN" \

  -H 'Content-Type: application/json' \

  -d '{"uses_allowed": 5, "expiry_time": null}'

И получаем что-то типа этого:

{"token":"uyRtuFpu.B~EVaVk","uses_allowed":5,"pending":0,"completed":0,"expiry_time":null}

С этим токеном уже можно зарегистрироваться:

Приглашение от админа — и мы в чате!

Ура, еще один рабочий чат!
Ура, еще один рабочий чат!

С этим аккаунтом уже можно и с телефона зайти. Не закрывайте при этом сессию на ПК, потому что там будет занятный квест с подтверждением устройства: мы же все-таки хотели безопасности. 

Я тут себе подчеркнул: надо будет изучить возможность добавления аккаунта на мобильные устройства через QR-код, чтобы было удобнее.

Шаг 8: подключение команды

Инструкция для участника занимает 5 минут:

1. Установить WireGuard-клиент (Windows, macOS, Linux, iOS, Android — все поддерживаются).

2. Импортировать конфигурацию через QR-код из wg-easy.

3. Подключиться к VPN.

4. Открыть https://element.team.internal в браузере.

5. Зарегистрироваться по токену.

6. Сохранить security key (ключ восстановления шифрования). Это критически важно, так как без него зашифрованные сообщения будут утеряны навсегда при потере всех активных сессий.

На мобильных устройствах: Element X (iOS/Android), после подключения к VPN указать homeserver URL. На Android пуш-уведомления работают через ntfy (UnifiedPush), полностью без google-зависимости. На iOS пуши проще оставить через стандартный matrix.org Sygnal relay. Содержимое сообщений при этом не передается — показывается только факт нового сообщения.

Модель авторизации

Немножко подробнее расскажу про концепт. В моем стеке нет единого центра авторизации. Доступ контролируется на двух независимых уровнях, и о каждом нужно помнить отдельно.

Первый уровень — WireGuard. Доступ к VPN определяется наличием конфигурационного файла с приватным ключом. Никаких логинов и паролей. Кто владеет .conf-файлом, тот в сети. Это одновременно и плюс (простота, нет кредов для фишинга), и минус (потерянный ноутбук с WireGuard-конфигом = у кого-то другого доступ к внутренней сети до момента отзыва ключа).

Второй уровень — Matrix/Synapse. У каждого пользователя есть аккаунт с паролем. Даже попав в VPN, без валидного аккаунта в Synapse прочитать переписку или отправить сообщение нельзя. E2EE добавляет третий слой: даже администратор сервера не может читать зашифрованные сообщения без ключей сессии.

SSO я сознательно не внедрял ПОКА ЧТО. Synapse поддерживает OpenID Connect, и можно было бы развернуть Authentik или Keycloak для единой точки авторизации. Но для 15 человек это ту мач: еще один сервис для поддержки, еще одна потенциальная точка отказа. При масштабировании до 50 человек и более это решение я, конечно, пересмотрю.

Чек-лист при уходе сотрудника

А что, если человечек ушел из команды? Тут составил универсальный чек-лист офбординга:

1. Удалить клиента в wg-easy (Admin Panel → «Удалить пользователя»). Эффект мгновенный, ключ сразу перестает приниматься сервером.

2. Деактивировать аккаунт в Synapse через Admin API: 

curl -X POST https://chat.team.internal/_synapse/admin/v1/deactivate/@user:chat.team.internal -H "Authorization: Bearer $ADMIN_TOKEN" -d '{"erase": false}'

Параметр erase: false сохраняет историю сообщений в комнатах, а erase: true заменяет имя и аватар на заглушки.

3. Если есть подозрение на компрометацию устройства, меняем Shared Secret в homeserver.yaml (если использовался для регистрации) и пересоздаем registration tokens.

Этот чек-лист у нас оформлен как закрепленное сообщение в приватной комнате админов. Действий всего на две минуты. Не идеально, но для команды нашего размера ок.

Заключение: что дальше

Резюмирую, что поднять корпоративный мессенджер в облаке реально и без сверхсложной инфраструктуры. Особенно если уже есть готовый сетап с понятной документацией.

Мессенджер есть куда масштабировать: хотелось бы докинуть позже мониторинг, централизованные логи, отдельные бэкапы и, в конце концов, внедрить ИИ-агента под пуши CI/CD. Не все из этого пока что до конца понимаю, как реализовать эффективнее, особенно в рамках того, что хочу разворачиваться на той же платформе. Есть вопросы по продуктам, которые, надеюсь, смогу разобрать на конфе GoCloud. Может, даже допилю что-то в рамках практической лабораторной работы, поспрашиваю специалистов от платформы и других коллег.

Хочу написать вторую часть с доработками, так что, если интересно, дайте знать в комментах. Критики, вопросов, советов — всего этого тоже жду. Может, кто-то уже поднимал для своей команды мессенджер? Делитесь опытом.