Summary

Я развернул у себя Excalidraw, Caddy Proxy Manager и LLM-роутер. В каждом случае README обещал «одну команду», а на деле я упирался в решения, которые авторы приняли давно и не афишируют: облако, зашитое в сборку, веб-морда, не умеющая жить за прокси, и окружение, которого нет у systemd. Вот три истории и чему они учат.

Три self-host истории: что казалось и что оказалось

История 1. Excalidraw, который звонит домой

Я люблю Excalidraw - в нём рисуются схемы к моим статьям. Хотелось поставить свою копию в контейнер на домашнем Proxmox, чтобы работать вдвоём в одной доске.

Агент клонировал репозиторий, собрал образ и сразу нашёл неприятное: продакшен-сборка выглядит как обычная статика за nginx, но в .env.production прописаны адреса сервисов автора. Хранилище сцен, библиотеки, сервер совместной работы и Firebase - всё ведёт на облако проекта.

Я расстроился и попросил: «Сделай без Firebase, чтобы не платить Google». Честный ответ агента был такой: эмулятор Firebase для продакшена не годится, так пишет сам Google. Остаются три пути:

  1. совместная работа без сохранения файлов вообще;
  2. локальный эмулятор (не рекомендуется);
  3. форк четырёх функций (saveToFirebase, loadFromFirebase и их файловые близнецы) с подменой на свой backend: Postgres или SQLite плюс MinIO.

Для живого редактирования хватило тонкого сервера комнат на socket.io - он не требует ни Redis, ни базы. В итоге появился рабочий docker-compose.collab.yml с опциональным собственным Firebase-проектом.

Отдельная находка про безопасность: правила Firestore у Excalidraw разрешают чтение и запись документа, но не листинг. То есть вся защита держится на том, что ID комнаты невозможно угадать. Для доски со схемами нормально. Для чего-то секретного - нет.

Tip

Если проект выглядит как «просто статика», откройте .env.production. Часто именно там спрятано всё, что держит функцию на чужом облаке.

История 2. Keenetic, который не хочет жить за прокси

Я переезжал с Nginx Proxy Manager на Caddy Proxy Manager. Причины: слой L4 из коробки, WAF на основе Coraza, гео-блокировки, forward-auth и mTLS. Но экосистема «панелей для Caddy» оказалась куда менее зрелой, чем у NPM, и выбирать пришлось вдумчиво.

Быстро появились первые странности.

Веб-интерфейс роутера. По IP-адресу вход работает. Через мой домен - нет. Я долго разбирал заголовки Host, Origin, X-Forwarded-*, пробовал разные варианты reverse_proxy со слиянием JSON. В итоге вывод был неприятный: роутер просто не поддерживает вход через произвольный обратный прокси. Штатные пути - собственное облако производителя или VPN. Не каждый баг про ваш прокси, иногда апстрим для такого не проектировали.

WebSocket у видеорегистратора. В NPM есть галочка «Websockets support». В Caddy её нет, и это нормально: reverse_proxy сам всё умеет. Но соединение всё равно падало. Виноваты оказались две мелочи:

  • Caddy сопоставляет пути точно, а не по префиксу. Чтобы поймать всё вложенное, нужна звёздочка в конце;
  • в настройках остался Path Prefix Rewrite, доставшийся от другого сценария.

Рукопожатие WebSocket при этом проходит (ответ 101) и тут же обрывается. Это значит, что закрывает его сам сервер за прокси, скорее всего, придираясь к Host или Origin. Не прокси.

Невнятная ошибка при загрузке конфига. Caddy отказывался применять правила блокировщика с сообщением invalid CIDR "0.0.0.0". Оказалось, нужна маска: 0.0.0.0/0 и ::/0.

И напоследок важное правило: административный API Caddy на порту 2019 нельзя выставлять наружу. Это консоль управления всем прокси.

История 3. LLM-роутер и «работает из консоли, падает как сервис»

Третий проект - OmniRoute, свой OpenAI-совместимый роутер к языковым моделям. Я поставил его в контейнер Proxmox, причём через npm, а не через Docker: запускать Docker внутри LXC не рекомендуют.

Из консоли работало сразу. Как systemd-сервис - нет.

  1. status=203/EXEC - systemd не нашёл бинарник. Он лежал не в /usr/bin, а внутри каталога nvm.
  2. Поправил путь - теперь EADDRINUSE. Дело в том, что сервис уже работал, запущенный вручную ранее, и держал порт.
  3. Рабочий unit получился, когда я явно прописал PATH внутри нужной версии Node.
[Service]
Environment=PATH=/root/.nvm/versions/node/<версия>/bin:/usr/local/bin:/usr/bin
ExecStart=/root/.nvm/versions/node/<версия>/bin/omniroute

Урок старый как мир: окружение интерактивной сессии не равно окружению сервиса. То, что видит ваша консоль, systemd не видит.

Два домена для одного сервиса

Самая полезная мысль этой истории пришла уже при публикации. У сервиса два типа клиентов.

  • Человек открывает панель в браузере. Для него подходят Basic Auth или forward-auth.
  • Программа (редактор кода, консольный клиент) ходит в API. Basic Auth её только сломает.

Поэтому я развёл их на два разных домена: «человеческий» с аутентификацией и «машинный» с проверкой ключа или списком разрешённых адресов. Так не приходится выбирать между удобством и защитой.

Что объединяет три истории

ПроектЧто казалосьЧто оказалось
ExcalidrawСтатика за nginxСборка зашита на облако автора
Keenetic за проксиПроблема в заголовкахАпстрим не рассчитан на домен
OmniRouteПросто запустить сервисУ systemd другой PATH

Каждый раз ошибка была не в моём конфиге, а в скрытом допущении. Поэтому я теперь делаю три вещи, прежде чем ставить что-то «одной командой»:

  1. Читаю не только README, но и .env.production, Dockerfile и список внешних адресов.
  2. Проверяю, что не будет работать при моей схеме доступа (за прокси, за VPN, без интернета).
  3. Разделяю доступ для людей и программ.

Честная документация self-host-проекта должна говорить не только «вот docker-compose», но и «вот что в этой конфигурации не заработает».