
TL;DR. 2 ноября сервер моей соцсети «Агора» на Beget перестал отвечать. 3 ноября хостинг подтвердил: данные VPS обнулены, бинарные бэкапы потеряны. Меня спасли три вещи: зашифрованные офсайт‑бэкапы базы, которые я настроил за день до аварии; escrow‑ключ для личных сообщений, заложенный в схему ещё раньше; и скрипты переезда, которые я успел написать в репозитории. Агора открылась на новом сервере примерно через 3 часа после того, как Beget подтвердил потерю. Цена: примерно сутки данных и несколько граблей, о которых расскажу ниже. Главная моя ошибка: мастер‑ключ шифрования лежал только в окружении серверного процесса и в бэкап не входил.
Коротко для тех, кто не читал прошлые статьи
Я Арсений, единственный разработчик «Агоры», российской соцсети для учеников, учителей и студентов. В первой статье я рассказывал, как написал её с нуля, во второй разбирал комментарии и то, что изменилось за три месяца. Сейчас пользователей около 200, и всё так же без единого рубля на рекламу.
Стек, чтобы было понятно, что именно падало:
React + TypeScript + Vite, статика сайта лежит на обычном хостинге (jino);
self‑hosted Supabase (Postgres 17) в Docker, рядом Bun‑сервисы под PM2 и Caddy в Docker;
личные сообщения с E2E‑шифрованием;
Android‑приложение на Expo / React Native;
медиа в Яндекс Object Storage.
Весь бэкенд жил на одном VPS у Beget. Спойлер: это была главная архитектурная ошибка, и в выводах я к ней вернусь.

Хронология

01.11: днём я настроил офсайт‑бэкапы базы:
pg_dump→ GPG‑шифрование → Яндекс S3, недельные и месячные копии. Последняя копия сделана 01.11 в 03:00 UTC.02.11: сервер перестал отвечать: SSH сбрасывает соединение, API молчит. Статика сайта на другом хостинге жива. Позже выяснилось, что это кибератака на Beget (новость на Хабре).
03.11, около 15:10 UTC: Beget подтвердил, что данные VPS, S3 и K8s обнулены, бинарные бэкапы VPS потеряны, остались только файловые. Совет один: сменить все ключи.
03.11, около 18:00 UTC: Агора снова открыта на новом сервере.
В копии, с которой я поднимался: 203 пользователя, 1030 постов и около 1800 личных сообщений. Всё, что случилось после 03:00 UTC 01.11, потеряно, то есть примерно сутки.
Бэкап базы: настроил за день до аварии (повезло)
Я давно знал, что копия «на том же сервере» на самом деле не копия. Скрипт после ночного дампа шифрует его GPG (на сервере лежит только открытый ключ, закрытый у меня) и кладёт в Яндекс S3: воскресные копии храню 4 недели, копии первого числа 6 месяцев.
Тогда же я проверил весь путь: скачал, расшифровал, развернул, посмотрел, что таблицы на месте. Без такой проверки дамп ещё не бэкап: о проблемах узнаёшь в самый плохой день.
А вот настройки сервера в бэкап не входили. Ни .env, ни конфиги Caddy, ни compose‑файлы. Это оказалось самым болезненным, об этом дальше.
Мастер‑ключ потерян, личные сообщения нечитаемы
Личные сообщения в Агоре шифруются сквозным шифрованием. Приватные ключи пользователей хранятся на сервере, но зашифрованными мастер‑ключом отдельного сервиса ключей. Этот мастер‑ключ лежал только в окружении серверного процесса. В бэкап он не входил, офлайн‑копии у меня не нашлось.
В итоге база есть, а все приватные ключи превратились в набор нечитаемых байтов. Вся старая переписка нечитаема. Это моя ошибка.
Выручило то, что в схему заранее был заложен escrow: к каждому зашифрованному сообщению прилагается копия, которую открывает ключ оператора. Этот ключ хранился у меня локально, отдельно от сервера. Это был «план Б», в который я, честно говоря, не очень верил.

Я написал скрипт, который работает целиком на моём компьютере (ни тексты сообщений, ни escrow‑ключ никуда не уходят):
читает выгрузку шифротекстов и состав диалогов из копии базы;
генерирует новую пару ключей каждому из 203 пользователей, приватную часть оборачивает новым мастер‑ключом;
расшифровывает каждое сообщение через escrow;
шифрует заново: если в диалоге двое, то обычным форматом на пару ключей; если остался один, то на его ключ; если никого, то не трогает;
каждое новое сообщение проверяет обратной расшифровкой;
пишет SQL одной транзакцией, который я применяю на сервере до запуска сервиса ключей с новым мастер‑ключом.
Спойлер: Упрощённая логика скрипта (без ключей)
for (const msg of data.messages) { const text = await decryptEscrow(msg.escrow_content); // ключ оператора, только в памяти const members = membersOf[msg.conversation_id]; if (members.length === 2) { content = await encryptForPair(text, sender, peer); // на пару ключей assert(await decryptAsPeer(content) === text); // проверка с обеих сторон assert(await decryptAsSender(content) === text); } else if (members.length === 1) { content = await encryptForOne(text, members[0]); // на ключ единственного участника assert(await decryptAsOwner(content) === text); } else continue; // диалог без участников sql.push(updateMessage(msg.id, content)); }
Сначала я полностью прогнал скрипт на копии базы: ошибок не было. Только после этого пошёл боевой прогон.
Результат: новые ключи получили 160 пользователей, восстановлено 1218 сообщений, ещё 44 лежали в пустых диалогах (из них не осталось ни одного участника), ошибок 0. Клиенты сами замечают новый публичный ключ в профиле и забирают новый приватный из сервиса ключей.
Раз я писал про E2E в прошлых статьях, скажу про escrow отдельно. Escrow это компромисс, и делать вид, что его нет, я не буду. Он ослабляет идею «сервер ничего не может прочитать», зато именно он вернул людям переписку. Если у вас так не устроено, храните мастер‑ключ хотя бы в нескольких местах офлайн.
Новый сервер: переехал по скриптам из репозитория
Я арендовал VPS в Санкт‑Петербурге: 2 vCPU, 4 ГБ, 50 ГБ. План переезда и скрипты я написал ещё 02.11, пока ждал ответа от Beget, поэтому разворачивание шло по списку:
Шаг |
Что делает |
|---|---|
bootstrap |
система, SSH только по ключам, firewall, Docker, Bun, PM2 |
setup‑supabase |
ключи, |
restore‑db |
заливка дампа |
make‑env |
|
ecosystem (PM2) |
запуск сервисов |
Caddyfile |
HTTPS и домены |
Все ключи Supabase (JWT‑секрет, anon‑ и service‑ключи, пароль базы) пришлось сделать новыми: старые значения лежали на уничтоженном сервере. Из‑за этого появилась первая проблема.
Старые сборки приложения получали 401, починил в Caddy
Публичный anon‑ключ был вшит в уже вышедшие сборки Android‑приложения и в другие мои проекты. JWT‑секрет утерян, ключ теперь новый, и старые клиенты получали 401. За первые три часа набралось 106 неудачных входов по OTP. Обновление по воздуху (OTA) до людей со старыми сборками не доходило.
Решение: Caddy подменяет старый ключ на новый в заголовках apikey и Authorization и в query‑параметре (так подключаются websocket‑ы Supabase Realtime):
@old_apikey header apikey {$OLD_ANON_KEY} request_header @old_apikey apikey {$ANON_KEY} @old_bearer header Authorization "Bearer {$OLD_ANON_KEY}" request_header @old_bearer Authorization "Bearer {$ANON_KEY}" @old_ws query apikey={$OLD_ANON_KEY} uri @old_ws query apikey {$ANON_KEY}
Значения подставляются из окружения Caddy. Это безопасно, потому что anon‑ключ публичный и даёт права только гостя. Заодно заработали сайты и приложения, которые я не успевал пересобрать.
Веб‑пуши умерли, Android‑пуши отправил вручную
Новые VAPID‑ключи значит старые подписки мертвы, браузеры переподписываются при следующем входе. Пуши в Android я разослал прямо с сервера по токенам устройств из офсайт‑копии (Expo‑токены от наших ключей не зависят).
Экран «технические работы» не показывался (завис ≠ упал)
Флаг тех. работ лежит отдельно, как status.json на статик‑хостинге, и задумано это было ровно на такой случай. Но экран не показывался. Причина в том, что сервер не упал, а завис. Запрос в базу без таймаута держал загрузку минутами, а флаг проверялся вместе с этим запросом.
Исправил так: AbortSignal на 8 секунд на запрос в базу, а если во флаге enabled: true, экран показывается сразу, не дожидаясь базы. Вывод: таймауты нужны везде, особенно в проверках «всё ли в порядке».
Мониторинг дважды промолчал
У меня была внешняя проверка доступности в GitHub Actions раз в час. Во время аварии она дважды не прислала алерт:
code=$(curl ...)подbash -eпадал на таймауте (код 28), и шаг умирал вместо отправки уведомления. Лечится|| true;четыре проверки с повторами не влезали в
timeout-minutes.
Исправленный кусок:
check() { for i in 1 2; do # || true: при таймауте curl выходит с ошибкой, и без этого # bash -e ронял весь шаг вместо уведомления code=$(curl -s -m 10 --connect-timeout 8 -o /dev/null -w '%{http_code}' "$2" || true) [ "$code" -ge 200 ] && [ "$code" -lt 500 ] && return sleep 10 done echo "• $1 (код $code)" >> problems.txt }
Ещё заметил, что GitHub‑cron срабатывал заметно реже, чем раз в час. Как единственная защита этого мало, но как внешняя проверка лучше, чем ничего.


Деплой не работал, потому что раннер стоял на упавшем сервере
Деплой шёл через self‑hosted раннер GitHub Actions, который стоял на самом сервере. Сервер лёг, и деплоить стало нечем. Я добавил в deploy.yml входной параметр runner: по умолчанию self‑hosted, но в аварию можно запустить на ubuntu-latest. Заливка с GitHub‑раннеров по FTP ловила таймауты хостинга, поэтому для неё я заменил FTP‑экшен на lftp mirror (заливка занимает около 15 минут).
Звонки: TURN‑сервер тоже был на Beget
Звонки 1-на-1 (WebRTC) требуют TURN‑сервера для пользователей за NAT. Мой TURN тоже стоял на Beget и тоже был уничтожен. Какое‑то время при нажатии на звонок показывалось сообщение «временно не работают» (флаг лежит в базе, чтобы клиенты не пытались соединяться зря).
Потом я поднял coturn на основном сервере. Раз он теперь делит машину с Supabase, ограничений пришлось добавить побольше:
слушает только публичный IP;
квоты на сессии и ограничение скорости;
запрещён relay на приватные и служебные диапазоны (защита от SSRF через TURN и от доступа к docker‑сетям Supabase);
без TCP‑relay и без CLI;
ограниченный диапазон relay‑портов.
Креды выдаются по схеме TURN REST: временное имя <время жизни>:<id пользователя> и пароль как HMAC от него. Эндпоинт сначала проверяет токен пользователя. Проверка через релей между разными сетями: около 115 пакетов в секунду в каждую сторону.
Ещё одна ловушка: TTL кредов я поднял до 8 часов, иначе звонок через релей обрывается, когда протухают креды.

Telegram‑алерты с нового сервера: IPv6 не доступен, HTTP/2 виснет
С нового сервера Telegram по IPv6 недоступен, а POST по HTTP/2 просто висел. Помогло curl -4 --http1.1. Сами алерты это скрипт, который каждые 5 минут проверяет диск, память, PM2, контейнеры, API и свежесть бэкапа и пишет сообщение только при смене состояния.
Мелочи, которые вылезли позже
Ссылки на старый CDN‑домен в базе (в постах, комментариях, группах) переписал на новый, остатков не осталось.
Модель NSFW‑проверки лежала только на старом сервере. После переезда проверка загрузок молча не работала: ни ошибки, ни предупреждения. Скачал модель заново, и сервис написал «модель загружена». Тихие отказы самые неприятные.
Платёжный магазин в ЮKassa оказался другим, чем в моих старых записях: на «старых» данных приходил 401. Разобрался, и платежи заработали.
Выводы
Ключи шифрования и
.envнадо класть в зашифрованный офсайт‑бэкап, а не держать только в окружении процесса. Теперь отдельный скрипт собирает все.env, конфиги Caddy и compose‑файлы в GPG‑архив и отправляет в облако.Бэкап, который не проверяли восстановлением, не бэкап. Мне повезло, что я успел проверить хотя бы базу. Но проверял только её, а про мастер‑ключ так и не спросил себя «а что, если его нет?».
Скрипты переезда надо хранить в репозитории. Не «я помню, как это делается», а
bootstrap.shи README с порядком шагов и списком подводных камней. Например, подсеть Docker для Supabase пришлось создавать вручную с конкретным диапазоном: триггеры в базе шлют вебхуки на фиксированный адрес шлюза.Мониторинг не должен жить на том же хосте, что и проверяемое. Раннер, алерты, проверки здоровья должны переживать падение основного сервера.
Таймауты везде: запросы к базе,
curl, джобы Actions. Зависший сервер хуже упавшего.Один провайдер это одна точка отказа. Это известная истина, но «хостинг большой, с бэкапами» оказался слабым аргументом: бинарные бэкапы VPS пропали вместе с самим VPS.
У E2E должен быть «план Б» (escrow или несколько офлайн‑копий мастер‑ключа), иначе одна потерянная переменная окружения стоит всей переписки.
Цифры на сегодня
~3 часа от подтверждения потери данных до открытой Агоры;
~сутки данных потеряно (копия от 01.11, 03:00 UTC);
203 пользователя, 1030 постов в копии;
1218 личных сообщений восстановлено, 0 ошибок;
106 неудачных OTP‑входов со старых сборок за первые три часа.
Что дальше
Осталось много уборки: часть настроек (например, конфиг coturn) всё ещё не входит в автоматический бэкап, и при следующем переезде мне придётся воссоздавать его по заметкам. TLS для TURN не настроен, клиенты откатываются на обычный порт. И я хочу, наконец, вынести мониторинг на отдельный сервер, чтобы он не падал вместе с остальным.
Половина проблем возникла из‑за моих же решений (ключ только в окружении, раннер на том же сервере, забытые таймауты). Но три вещи, сделанные заранее, помогли: вместо катастрофы вышел просто очень тяжёлый день.
Как и в прошлый раз, пишу, чтобы зафиксировать опыт. Если у вас есть вопросы по конкретным решениям или идеи, как сделать лучше, пишите в комментариях, буду рад разобрать.
Ссылка на проект, социальную сеть для учеников, учителей и студентов: agorasocial.ru.
Комментарии (7)

andreymal
04.11.2026 16:18примерно сутки данных
Репликация в реальном времени на сторонний сервер (или хоть на домашний комп) и потерь не будет вообще

langalier777
04.11.2026 16:18Какие облака и виртуальные сервера, зачем? Сколько там траффик в сутки у всей системы? Хороший сервер, туннели и как написали выше репликация на пару резервных ПК. Пока интернет и энергия есть ничего не упадет и стоит это все практически ничего. Для защиты от взлома, данные пишутся в единую захешированную базу, каждый аккаунт каждого пользователя при вызове базы использует ключ для доступа к модулю словарей-дешефровщиков, ключ создается только при подтвержденной активации сессии пользователем (тем же смс кодом), до активации сессии и сам аккаунт и база данных бесполезный набор символов, ломать там попросту нечего..., ну это я уже в хард совсем ушел, для тайных переписок учеников)
exadmin
Если вам и правда 15-16 лет, то, снимаю шляпу - вы очень хорошо подготовились и справились.
ars_stu Автор
Благодарю, очень приятно слышать :)