Своя self-hosted двухсерверная VPN-архитектура. Подробный путь разработки и ошибки, с которыми вы можете столкнуться
Проект представляет из себя быстрый способ воссоздать архитектуру состоящую из 2 серверов (локальный + удаленный) с определенными сервисами, которые решают специфические задачи. Проект сделан прежде всего для меня, а также для людей которые хотят свой готовый self-hosted сервер из коробки с полной системой обслуживания
Всё началось с того, что меня перестал устраивать мой подход к развертыванию личных сервисов. Я решил переписать всё с нуля. К тому же это был отличный повод получить новые навыки и попробовать инструменты, до которых давно не доходили руки.
Арендовать мощные VPS под ресурсоемкие задачи в текущих реалиях выходит неоправданно дорого. При этом дома у меня уже был выделенный неттоп (мини-ПК) с 16 ГБ оперативной памяти на базе энергоэффективного процессора Intel N95.
Пройдя путь от простых Bash-скриптов и сторонних туннелей до полностью автоматизированной инфраструктуры, я создал проект ServeHub-2. В этой статье я подробно разберу, как эволюционировала сеть проекта, почему декларативный подход победил императивный и с какими неочевидными багами пришлось столкнуться в процессе автоматизации.
История настройки сетевой архитектуры
Использование Tuna
В самом начале проекта я еще не думал о VPN как о способе пробития NAT и основе, на которой будет строиться вся система. Первое, к чему я пришел — сервис Tuna. Если кратко, это аналог Cloudflare Tunnel за условные 300 рублей. У него очень приятный веб-интерфейс и невероятно простая настройка. Однако для полноценной независимой архитектуры он не подошел по двум причинам:
- Сервера Tuna находятся вне контроля пользователя.
- На удаленном сервере нельзя развернуть свои сопутствующие сервисы.
В целом сервис действительно удобный, но для моих задач он оказался слишком ограничивающим.
Вот пример конфига для tuna (все максимально просто, создаем контейнеры для ssh туннеля, чтобы был доступ ssh, а также основной http туннель с привязкой к nginx или любому другому прокси если такой есть):
В поисках гибкости: тесты Pangolin и переход к FRP
После Tuna я решил двигаться в сторону собственного контроля и попробовал Pangolin. Однако решение быстро отвалилось: для дешёвого сервера оно оказалось слишком «тяжёлым» и избыточным. Главные минусы — ощутимый оверхед по ресурсам и лишний слой принудительной веб-аутентификации перед самими сервисами. Это ломало нормальное взаимодействие с родными мобильными клиентами (вроде Element для Matrix или Bitwarden для паролей), где повторная авторизация в браузере просто не нужна.
На смену пришел FRP (Fast Reverse Proxy). Он выполнял ту же функцию проброса, но хостил я его уже на собственном арендованном VPS в режиме Layer 4 (TCP). Это дало абсолютную гибкость: внешний VPS не заглядывал внутрь пакетов и ничего не расшифровывал, а просто пересылал сырой поток домой. Кроме того, это отлично оптимизировало расходы: вместо 300 рублей за Tuna и ещё 300 рублей за отдельный VPN, я стал платить всего 500 рублей за один стойкий VPS, который мог настраивать как хочу. (к тому же можно было обойтись даже дешевле, так как VPS с 4 гб оперативки загружен всего лишь на 40%, процессор всего на 20%-40%)
FRP состоит из 2 конфигурационных файлов (один на удаленном сервере frp server, другой на локальном frp client), все также довольно просто, однако его можно использовать на разных слоях. У меня он брал трафик за стандартный TCP и передавал его на локальный сервер.
Также доп. фишка в том что основная конфигурация происходит именно в frpc, который, в свою очередь, передает часть настроек на frp на удаленном сервере.
frpc.toml:
frps.toml:
Полноценный переезд на WireGuard (AmneziaWG) и 8 часов отладки
Со временем архитектура эволюционировала в сторону полноценной VPN-сети на базе WireGuard, а точнее — его модификации AmneziaWG. Причин для этого шага было несколько:
- Безопасность: FRP всё же открывал внутренние ресурсы в публичный интернет, оставляя их доступными для сканеров портов.
- Удобство маршрутизации: Все участники сети стали равноправными узлами в одной виртуальной локальной подсети. Больше не нужно было настраивать постоянные односторонние пробросы.
- Дополнительный бонус: Поскольку удаленный VPS был куплен в Нидерландах, через этот же VPN я автоматически получил безопасный доступ ко всем зарубежным ресурсам.
Для реализации схемы на удаленном VPS был развернут контейнер wg-easy с поддержкой AmneziaWG, а на домашнем мини-ПК — клиентский контейнер Amnezia (сборка из Dockerfile с использованием amnezia-tools и модулем ядра хоста).
И именно здесь я поймал самый изнурительный баг проекта. После развертывания трафик упорно шёл только в одну сторону. Часов 8 ушло на диагностику iptables, маршрутов и чтение зарубежных форумов (что бесполезно, учитывая специфику наших блокировок). Оказалось, провайдер просто дропал обратный трафик стандартного WireGuard, так как пакеты шли без маскировки. Причина крылась в docker-compose.yml: у меня было прописано image: ghcr.io/wg-easy/wg-easy:latest. Как выяснилось, тег latest на Docker Hub намертво прилип к старой 14-й версии, а поддержка параметров AmneziaWG появилась только в ветке 15.x. Изменение тега на конкретную версию (15.3) решило проблему за секунду.
Благодаря переходу конфигурация получилась максимально простой, основные отличия от стандартной документации выделил в коде: (в основном то, на что пришлось долго рыть информацию)
Настройка Nginx
Чтобы сервисы были доступны исключительно внутри подсети VPN, я задействовал Nginx. Доступ к приложениям был жестко ограничен на уровне конфигурации — веб-сервер принимает запросы только из диапазона IP-адресов 10.8.0.0/24. Любые попытки постучаться на сервер из внешнего интернета без активного VPN-туннеля автоматически сбрасываются Nginx.
Логика распределения завязана на proxy-протоколе: Nginx на удаленном VPS выступает основным входным узлом, принимает зашифрованный трафик, заворачивает его в заголовки с реальным IP-адресом клиента и через туннель перекидывает на локальный Nginx домашнего сервера. Локальный веб-сервер уже сам расшифровывает SSL и распределяет трафик по конечным Docker-контейнерам, сохраняя реальные IP в логах безопасности.
Вставлю один кусок кода из nginx на удаленной машине для примера, так все остальное примерно похоже (конфиги использовались в виде .template):
SSL-сертификаты
Для получения валидных SSL-сертификатов я настроил работу через автоматический Certbot по challenge-валидации DNS-01 c API Webnames. Сам домен привязан к внутреннему IP-адресу 10.8.0.1. Проверка через DNS позволила выпустить единый wildcard-сертификат на весь домен и его поддомены без необходимости держать открытым 80-й порт веб-сервера наружу.
Уточню, что certbot запускается автоматически во время выполнения Ansible плейбука, поэтому самому кроме указания переменных ничего делать не нужно.
В итоге получилась схема, при которой все сервисы доступны по красивым доменным именам с HTTPS, но абсолютно невидимы для внешнего интернета.
Вот настройка certbot:
История софта
Параллельно с сетевой структурой развивался и сам набор приложений. Изначально я хотел собрать в одном месте утилиты, которыми пользуюсь каждый день, но в процессе селфхостинга быстро понимаешь: нельзя просто накидать контейнеров и надеяться, что мини-ПК справится, а конфигурационные файлы не превратятся в кашу.
Первый стек и оптимизация
Первыми на домашнем сервере прижились медиа-сервисы: Navidrome для стриминга музыки и Audiobookshelf для аудиокниг и подкастов. Они легковесные, имеют отличные мобильные клиенты с синхронизацией прогресса и полностью закрывают мои потребности. Позже к ним добавился Nextcloud как единое независимое облако для файлов, контактов и семейных документов.
Затем встал вопрос безопасного хранения паролей. Сначала я смотрел в сторону оригинального Bitwarden, но в итоге я выбрал Vaultwarden — альтернативный сервер на Rust, полностью совместимый с API Bitwarden. Он потребляет считанные мегабайты оперативной памяти и работает идеально. Дополнительно для удобства управления всей этой распределенной Docker-инфраструктурой в локальный стек был добавлен Portainer. (+ Portainer Agent на удаленный сервер)
Из интересных моментов, где мне пришлось немного больше возиться, чем с остальными сервисами это nextcloud настройка:
Ошибки проектирования: почему я удалил Matrix (Synapse)
Не все решения прошли проверку временем. На этапе использования Tuna и FRP я развернул сервер Matrix (Synapse) для защищенного обмена сообщениями. Мне казалось это крутой идеей, но когда я окончательно перешел на AmneziaWG, целесообразность мессенджера внутри закрытого туннеля сошла на нет.
Synapse требовал слишком много ресурсов, впустую расходовал оперативку домашнего ПК и усложнял конфиг Nginx. При этом реальной пользы для семьи он не приносил. Для критических алертов инфраструктуры и повседневного общения проще и эффективнее оказалось использовать Telegram. (плюс алертинг настроен именно через него) В итоге я полностью выпилил Synapse из стека, освободив ресурсы.
Вместо него я добавил в связку к wg-easy локальный AdGuard Home. Теперь он работает прямо внутри VPN-сети: очищает весь трафик от рекламы и трекеров на лету, кэширует DNS-запросы и не дает истории веб-серфинга улетать внешним провайдерам.
Основной проблемой с которой я столкнулся при использовании AdGuard Home, так это то что я так и не понял как заставить использовать AmneziaVPN клиент AdGuard как основной DNS, при этом AmneziaWG работает прекрасно. Как я понимаю дело в том что AmneziaWG работает намного проще на уровне ip и у него нету никаких доп фильтров, настроек и подобного, поэтому он просто берет данные из конфига.
От костылей на Bash к декларативному Ansible
Весь стек на обоих серверах разворачивался через bash скрипты, что было уж очень плохо с точки зрения идемпотентности. Во первых из-за bash мне постоянно приходилось очищать сервера, так как нормальных проверок у меня не было и писать я их не хотел, а также было много костылей с импортом переменных, записями в файлы и подобным.
Так я пришел к декларативному подходу и Ansible. Теперь вся конфигурация описывается в виде плейбуков и ролей, отражающих конечное желаемое состояние серверов. Конфиденциальные данные перенесены в файл secrets.yml, а хрупкие конструкции автоматизированы через шаблоны Jinja2. Проект стал идемпотентным: если шаги уже выполнены, Ansible их просто пропускает.
В итоге получилось несколько yaml файлов для стандартной настройки системы (bootstrap_os.yml) и для настройки каждого из хостов (setup_local.yml и setup_remote.yml). В итоге теперь все что нужно чтобы полностью с нуля развернуть проект - скачать Ansible, несколько других зависимостей на свой рабочий пк и запустить один manage_deploy.sh, в котором можно будет выбрать сценарий как будет вести себя Ansible и спокойно дождаться разворачивания сервисов.
Также благодаря Ansible я удобно реализовал переносимость проекта. Так как я решил не использовать тома docker, и вместо этого храню все в папках, чтобы перенести старые данные проекта нужно просто скопировать папку apps-data и положить ее в нужное место и все само заработает после повторного развертывания проекта. В Ansible выделяется отдельная пауза для этого.
Вот пример основного плейбука deploy.yml:
Тестирование мультидистрибутивности с помощью Vagrant
Проект изначально затачивался под работу на трех дистрибутивах: Ubuntu, Debian и Arch Linux. Тестировать Ansible-плейбуки прямо на рабочей локальной машине (в моем случае — EndeavourOS) слишком рискованно, а создавать виртуальные машины руками — долго и неудобно.
Решением стал Vagrant, позволяющий за пару минут развернуть чистые ОС в VirtualBox из готовых образов. Но в процессе настройки мультивендорного стенда в режиме сетевого моста (public_network) всплыли две критические проблемы:
- Конфликт DNS: По умолчанию Vagrant создает NAT-интерфейс для управления нодой. При включении второго (публичного) интерфейса для локальной сети ломался дефолтный DNS-резолвер. Проблему пришлось решать принудительной очисткой и перезаписью файла /etc/resolv.conf через inline-скрипт автоматизации Vagrant.
- Проблема с GRUB на Debian: В используемом базовом образе generic/debian12 конфигурация GRUB сохраняла жесткую привязку к конкретному имени диска из окружения сборщика. При повторном развертывании плейбуков на тестовом стенде это приводило к сбоям загрузчика. Чтобы автоматизировать очистку, пришлось внедрить скрипт, который на лету определяет имя системного диска через lsblk и автоматически передает правильные параметры в загрузчик через утилиту debconf-set-selections.
Вот код Vagrantfile: (в node.vm.provision происходит основное решение ошибок)
Надежная система бэкапов на базе BorgmaticДля создания резервных копий я внедрил Borgmatic (удобную надстройку над дедуплицирующим инструментом Borg Backup). Весь процесс автоматизирован с помощью связки системных юнитов borgmatic.service и borgmatic.timer. В бэкап уходят две ключевые директории: apps-data (конфигурации приложений и баз данных) и PersonalData (медиатека: музыка, книги, подкасты, файлы Nextcloud).
Развертывание системы бэкапов полностью берет на себя Ansible. Мне достаточно указать UUID внешнего жесткого диска — скрипт сам проверит его наличие в системе, примонтирует в нужную директорию, создаст зашифрованный репозиторий и настроит политику ротации (хранение 7 ежедневных, 4 еженедельных и 6 ежемесячных копий). Также в Prometheus выведен мониторинг самого репозитория .borg для отслеживания его размера и статуса успешности архивации.
Главная проблема при бэкапе работающих Docker-контейнеров — риск скопировать базу данных в «битом» или неконсистентном состоянии, если в момент создания архива в нее шла активная запись. Чтобы решить эту проблему, я задействовал механизм хуков в конфигурации Borgmatic.
Перед началом резервного копирования автоматически срабатывает команда остановки контейнеров проекта (docker-compose down), а после успешного завершения (или в случае возникновения непредвиденной ошибки) контейнеры автоматически поднимаются обратно в фоновом режиме. Для удобного просмотра архивов и быстрого восстановления файлов я развернул веб-интерфейс Borg UI.
Конфигурация borgmatic: (использую как Jinja2 шаблон, чтобы Ansible в плейбуках сам подставил переменные)
Наблюдаемость (Observability) уровня Enterprise
В последних релизах (v1.2.0 и v1.3.0) фокус проекта сместился на мониторинг и работу с логами.
Эволюция алертинга и переход на Gatus
Изначально для мониторинга доступности я смотрел на Uptime Kuma, но отказался из-за отсутствия удобной декларативной настройки через конфиги. Затем я развернул связку Blackbox Exporter и Alertmanager с уведомлениями в Matrix. Но тут крылась логическая несостыковка: весь алертинг был завязан на локальном ПК, и в случае его аппаратного отказа я бы просто лишился уведомлений.
Тогда я решил вернуть Uptime Kuma, но развернуть его на удаленном VPS в качестве внешнего «сторожа» и автоматизировать его настройку через Python-скрипт с библиотекой uptime-kuma-api. Но и тут ждали «грабли» — библиотека не обновлялась три года и намертво ломалась на свежих версиях Kuma.
В итоге идеальным решением стал Gatus. Он изначально проектировался под управление через YAML-конфиги и поддерживает отправку алертов, если сервер не отвечает. Из-за специфики фронтенда Gatus (он не умеет работать из подкаталога типа /gatus), мне пришлось перенести его и wg-easy на полноценные субдомены gatus. и wireguard.
Для Gatus получился простой конфиг:
Для мониторинга аппаратных ресурсов удаленного и локального серверов была развернута связка node-exporter + cAdvisor. Все уведомления теперь приходят мгновенно в Telegram-бота. Чтобы избежать лавины одинаковых сообщений (например, при перезагрузке хоста), в Alertmanager настроена жесткая группировка и дедупликация событий. Также добавлен экспортер для AdGuard Home, выводящий статистику заблокированных запросов в Grafana.
Централизованные логи: Loki + Grafana Alloy
В релизе v1.3.0 в стек была добавлена централизованная система сбора логов Loki. Вместо устаревшего Promtail в качестве агента сбора я применил Grafana Alloy.
Он эффективно собирает логи со всех запущенных Docker-контейнеров, парсит их и передает в Loki. Теперь вся история событий, ошибок веб-сервера Nginx или падений внутренних приложений доступна в едином интерфейсе Grafana с возможностью удобной фильтрации через LogQL, что значительно упрощает отладку.
Конфиг Loki:
Конфиг Grafana Alloy: (локальный конфиг)
Интерфейс: переход на Homepage
Изначально для домашней страницы я написал кастомную минималистичную HTML-панель с Glassmorphism-дизайном. Выглядело это красиво, но добавлять новые сервисы вручную через постоянную правку исходного кода было крайне неудобно.
В итоге я заменил самописную страницу на полноценный комьюнити-проект Homepage. Это дало некоторую гибкость: вся панель настраивается через простые YAML-файлы и поддерживает встроенные виджеты интеграции. Пока что она простенькая, но возможно в будущем сделаю что-то более продвинутое.
Заключение
Постарался подробно рассказать о структуре проекта и как я к этому пришел, очень много я не добавил, если пост зайдет, то я обязательно более подробно пройдусь по некоторым моментам: как я настраивал Matrix и на каком моменте я решил его убрать, как собирал локальный amnezia контейнер-клиент, что нового я добавил в релизе 1.4.0 и другое.
Также я перешел с manage_deploy.sh на отдельный GUI клиент, который будет полностью автоматизировать процесс подготовки к deploy. (написан на Go Wails). Про это тоже возможно сделаю статью.
Вся кодовая база проекта, подробная документация, инструкции по развертыванию открыты и доступны для сообщества:
🔗 GitHub-репозиторий: https://github.com/canntstand/ServeHub-2
Это моя первая статья на этом сайте и в то же время первый личный проект, которому я отдал так много времени (3 месяца). Буду рад вашему фидбеку в комментариях!

