
Недавно я писал на Хабре, как собрал каталог MCP-серверов: 80 тысяч записей из девяти источников в одном индексе. В комментариях и в личке быстро всплыл вопрос, который я сам не до конца продумал, когда делал каталог: «нашёл сервер, а запускать его где?»
С MCP-серверами беда в том, что добрая половина живёт не как готовый npm-пакет, а как репозиторий на GitHub. Его надо склонировать, поставить зависимости, собрать, запустить и держать живым. Если сервер stdio (а таких большинство), сверху ещё задача обернуть его в HTTP, иначе к нему не подключится ни claude.ai, ни ChatGPT. Итог: под сервер на 40 строк человек поднимает VPS, пишет Dockerfile, настраивает nginx, выпускает сертификат и вешает вебхук на автодеплой. Дизбаланс усилий чудовищный.
Поэтому я сделал внутри Unyly полноценный раздел деплоя. Подключаешь GitHub-репозиторий, на выходе получаешь работающий проект на slug.unyly.org, пуш в ветку пересобирает его сам. Работает и для сайтов (статика, фронтенд-сборки, Next.js), и для MCP-серверов на Node и Python. Ниже разберу архитектуру по слоям и честно перечислю грабли, на которых потерял время. Кода будет много.

Общая схема
Ничего экзотического, вся суть в аккуратной склейке обычных кирпичей. Поток такой:
GitHub push -> вебхук на Next.js API (HMAC-проверка) -> запись в очередь runner_deploys (SQLite) -> демон-воркер берёт задачу git clone токеном юзера генерация Dockerfile под тип проекта docker build docker run с лимитами healthcheck -> реверс-прокси на :3900 роутит по Host -> нужный контейнер
Важная деталь, до которой я дошёл не сразу: прокси и воркер это два разных процесса под pm2 (unyly-runner-proxy и unyly-runner-worker), а не один. Почему, расскажу в разделе про грабли, там это стоило мне пары часов и всех живых сайтов.
Слой 1. Генерация Dockerfile
Пользователь Dockerfile не пишет. Если он есть в репозитории, я уважаю его и беру как есть. Если нет, генерирую сам по типу проекта. Пять рецептов.
MCP-сервер на Node. Точку входа ищу в порядке bin > main > index.js из package.json. Ключевой момент: stdio-процесс оборачивается в supergateway, который превращает stdin/stdout сервера в streamable HTTP на /mcp. Именно это делает сервер доступным для сетевых клиентов.
FROM node:22-slim WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev --no-audit --no-fund || npm install --no-audit --no-fund COPY . . RUN npm install -g supergateway@latest --no-audit --no-fund EXPOSE 3000 CMD ["sh", "-c", "supergateway --stateful --stdio 'node index.js' \ --outputTransport streamableHttp --port 3000 --streamableHttpPath /mcp"]
MCP-сервер на Python. То же самое, только базовый образ python:3.12-slim, зависимости из requirements.txt или pyproject.toml, а supergateway (он на Node) доставляется поверх через apt-get install nodejs npm. Точка входа server.py или main.py.
Сайт, три рецепта как у нормального деплой-хостинга:
Чистая статика (есть
index.html, нетpackage.json) едет вnginx:alpine.Фронтенд со сборкой: гоняю
npm run buildи раздаю то, что нашлось. Тут забавный кусок, который вычисляет выходную папку перебором стандартных имён:
RUN for d in dist build out public .; do \ if [ -f "$d/index.html" ]; then cp -r "$d" /site && break; fi; \ done && test -f /site/index.html
Next.js определяю по зависимости
nextвpackage.jsonи запускаюnext startрантаймом.
Для сайтов nginx-конфиг с SPA-фолбэком лежит отдельным файлом и добавляется через COPY, а не генерируется в RUN. Почему именно так, тоже в граблях, там красивая история про $uri.
Слой 2. Запуск контейнера
Собранный образ поднимается с жёсткими лимитами. Это чужой код на моём железе, поэтому политика простая: минимум ресурсов, минимум прав.
docker run -d --name <container> --restart unless-stopped \ --memory 384m --cpus 0.5 --pids-limit 256 \ --cap-drop ALL --security-opt no-new-privileges \ -p 127.0.0.1:<port>:3000 \ <image>
Порт слушается только на localhost, наружу его выставляет реверс-прокси. --cap-drop ALL убирает все линуксовые capabilities. Одно исключение: nginx в site-образах падает на старте без CHOWN, SETUID, SETGID (ему надо переключить воркеры на unprivileged-юзера), поэтому именно этим трём контейнерам я возвращаю только их. MCP-контейнеры живут вообще без capabilities.
Слой 3. Healthcheck, который реально проверяет
Вот тут я принципиально не хотел ограничиваться «процесс поднялся, значит живой». Для сайта проверка простая: HTTP-код 2xx или 3xx на /. А вот для MCP-сервера healthcheck это настоящий JSON-RPC initialize по протоколу MCP, с дедлайном 60 секунд:
curl -s --max-time 5 -X POST http://127.0.0.1:<port>/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-03-26","capabilities":{}, "clientInfo":{"name":"unyly-health","version":"1.0"}}}'
Если в ответе есть "result", сервер действительно говорит на MCP, а не просто открыл порт. Не прошёл за минуту: тяну последние 40 строк docker logs прямо в лог деплоя, чтобы человек в кабинете видел причину, а не «failed».
Слой 4. Роутинг по Host и nginx-регэксп
Реверс-прокси на :3900 смотрит на заголовок Host и по нему выбирает контейнер: slug.unyly.org, кастомный домен или превью пулреквеста. nginx отдаёт весь wildcard-поддомен в этот прокси. И вот здесь спрятана мина, на которой я подорвался (детали в граблях). Правильный server_name это регэксп с негативным просмотром вперёд, который исключает служебные поддомены:
server_name ~^(?!(www|app|api|admin|gateway|staging|run|mail|smtp|ftp|deploy)\.)[a-z0-9-]+\.unyly\.org$;
То есть «любой поддомен, кроме перечисленных». Простой *.unyly.org тут ставить нельзя категорически.
Слой 5. Автодеплой и превью пулреквестов
При подключении репозитория я вешаю GitHub-вебхук на события push и pull_request. Подпись проверяю честно, X-Hub-Signature-256, HMAC-SHA256 от сырого тела:
const expected = 'sha256=' + createHmac('sha256', project.webhook_secret).update(raw).digest('hex') // далее timingSafeEqual, чтобы не течь по времени сравнения
Push в отслеживаемую ветку кладёт задачу в очередь, проект пересобирается, история деплоев и логи остаются в кабинете. С пулреквестами интереснее: на каждый PR я поднимаю отдельный проект-превью со своим слагом (parent_id указывает на основной проект, плюс pr_number), билжу ветку PR и оставляю ссылку комментарием прямо в пулреквест. Закрыли PR, превью удаляется само. Форк-пулреквесты не билжу, там чужой код с потенциальным доступом к секретам, это осознанно.
Что получилось на выходе

Управление вынесено на отдельный поддомен deploy.unyly.org: проекты, деплои с логами сборки, runtime-логи, метрики CPU и RAM, график трафика, кастомные домены, откаты. Отдельная история была с авторизацией этой консоли, о ней ниже в граблях.
Тарифно деплой встроен в общую подписку Unyly, а не продаётся коробкой: на бесплатном плане один проект, на Pro десять плюс превью PR и свои домены.

Живой минимальный пример прямо сейчас крутится на hello.unyly.org: один index.html, задеплоен из тестового репозитория, пуш пересобирает.

Отдельно я задолбался, но сделал все служебные страницы брендированными. Проект собирается, стоит на паузе, просыпается из сна или слага не существует: везде пиксельный единорог и внятный текст вместо голого nginx-ного 404 Not Found. Даже фавиконку, если у сайта своей нет, отдаю единорога, чтобы вкладка не выглядела сиротой.

Грабли, ради которых обычно и читают
Билд глушил вообще все сайты. Прокси и воркер сначала жили в одном node-процессе. Синхронный docker build через execFileSync блокировал event loop целиком. Пока собирается один проект (а это десятки секунд), реверс-прокси в том же процессе не отвечает, и все раннер-сайты разом отдают 502. Развёл на два pm2-процесса, роль передаётся аргументом. Задним числом очевидно, в моменте нет.
printf в Dockerfile ломал nginx. SPA-фолбэк для сайтов сначала генерировал прямо в RUN через printf. Внутри конфига try_files $uri $uri/ /index.html, и переменную $uri съедал шелл на этапе сборки. Получался конфиг без $uri, nginx уходил в бесконечный редирект-цикл, отдавал 500. Лечится тем, что конфиг лежит отдельным файлом и приходит через COPY, никакого экранирования.
Wildcard-поддомен положил админку. Самая эпичная. Повесил на прокси раннеров *.unyly.org и он радостно перехватил admin.unyly.org и gateway.unyly.org, которые обслуживаются в другом server-блоке. Админка легла с «not found», я получил от коллеги скриншот с подписью «бро втф». Правильный путь только регэксп с негативным просмотром вперёд (см. слой 4), и default_server на этом блоке ставить тоже нельзя, иначе он начнёт ловить лишнее.
Куки не ходят между поддоменами. Консоль на deploy.unyly.org, а сессия у меня host-only на основном домене. Cross-subdomain куки я сознательно не включаю: Brave и Safari с их трекинг-защитой роняют на них OAuth. Пришлось сделать SSO-мостик. Основной домен под живой сессией выдаёт короткоживущий подписанный токен формата v1.<userId>.<exp>.<hmac>, действителен 60 секунд. Консоль принимает его один раз и меняет на свою host-only куку deploy_session на 30 дней. HMAC на общем секрете, timingSafeEqual при проверке. Скучно, зато не разваливается в приватных браузерах и не зависит от политики третьих кук.
stdio-серверы без обёртки бесполезны в сети. Первый заход был «просто запусти процесс». Но stdio-MCP общается через stdin/stdout, к нему нельзя подключиться по HTTP. Без supergateway (или аналога) сетевой MCP-сервер физически не существует. Это не грабля даже, а фундамент, который я поначалу недооценил.
Зачем это в каталоге, а не отдельным продуктом
Могло показаться, что деплой уводит в сторону от каталога. На деле он замыкает петлю. Раньше Unyly отвечал на вопрос «какой MCP-сервер взять». Теперь отвечает и на «где его держать». Нашёл сервер в каталоге или написал свой, и через пару минут он живёт на своём URL, готовый к подключению, без отдельного хостинга и DevOps-обвязки.
Пощупать: unyly.org/deploy, консоль на deploy.unyly.org. По архитектуре и граблям готов отвечать в комментариях предметно, прошлый раз обсуждение вышло полезным, рассчитываю и в этот.

