Недавно я писал на Хабре, как собрал каталог 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-репозитория в работающий проект
Из GitHub-репозитория в работающий проект

Общая схема

Ничего экзотического, вся суть в аккуратной склейке обычных кирпичей. Поток такой:

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.

Сайт, три рецепта как у нормального деплой-хостинга:

  1. Чистая статика (есть index.html, нет package.json) едет в nginx:alpine.

  2. Фронтенд со сборкой: гоняю 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
  1. 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 и свои домены.

Deploy в тарифах
Deploy в тарифах

Живой минимальный пример прямо сейчас крутится на 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. По архитектуре и граблям готов отвечать в комментариях предметно, прошлый раз обсуждение вышло полезным, рассчитываю и в этот.