Настройка HTTPS сервера: как я поднял llm-router на Caddy
Утром мне прилетело короткое сообщение: «Баратишка, мы переехали на новый сервер, и теперь данный проект у меня не работает нигде». Речь про llm-router - маленький, но критичный сервис, через который несколько моих проектов ходят к подписочным LLM. И он лёг. Не «частично тормозит», а именно не работает нигде. Классика переезда: код доехал, а жизнь из него куда-то делась, и всё в итоге упёрлось в настройку https сервера на новом месте - reverse proxy и сертификаты просто не подхватились.
В этой статье я разберу всю историю целиком — от диагностики «почему оно молчит» до финального результата, где сервис не только поднялся, но и оброс продакшн-обвязкой: systemd-автозапуском, HTTPS через Caddy, разделением UI и машинного API, а также отдельным admin-API, которым мои же ИИ-агенты могут сами переключать провайдеров и модели. Если вы когда-нибудь переносили Node.js-сервис между серверами и ловили ощущение «вроде всё на месте, но ничего не запускается» — узнаете себя.
Что такое llm-router и зачем он вообще нужен
Сначала контекст, иначе половина решений будет непонятна. llm-router — это control plane для доступа к LLM по подпискам. Его задумка в одной фразе: проксировать подписочный доступ к моделям (Claude Max, ChatGPT Plus, Google Code Assist / Gemini) к моим зарегистрированным проектам, выдавая каждому проекту свой токен.
Ключевое слово здесь — подписки, а не API-ключи. Это архитектурный инвариант всего проекта: llm-router принципиально не работает через платные pay-as-you-go API-ключи. Он берёт OAuth-токены от уже оплаченных подписок и раздаёт доступ проектам, которые сами по себе не имеют прямого доступа к моделям. Проект получает Bearer <project-token>, дёргает привычный эндпоинт в формате /v1/messages, а роутер под капотом подставляет нужную подписку и модель. Токены нельзя выпустить самому — их выдаёт оператор через дашборд.
Это важно держать в голове, потому что во время работы дважды всплывал соблазн «а давай для MVP возьмём просто api_key из AI Studio» — и дважды это отвергалось. Об этом ниже.
Диагностика: почему «код есть, а сервиса нет»
Первое правило при таком баг-репорте — не кидаться чинить наугад. Симптом «не работает нигде» слишком общий, чтобы по нему сразу лечить. Поэтому я начал с разведки: что физически есть на новом сервере, а чего нет.
Картина сложилась быстро и оказалась до боли типичной для миграции. Что доехало:
- Сам код проекта — на месте, в
/home/deploy/projects/llm-router. - Данные авторизации подписок — тоже на месте: файлы с credentials Claude и Codex, а также профили аутентификации и проектные токены роутера.
А вот чего на новом сервере не было:
node_modules— зависимости банально не установлены.- Ни одного живого процесса на порту
3200, где роутер должен слушать. - Никакого механизма автозапуска — ни systemd-юнита, ни cron, ни docker-compose. То есть даже если запустить руками, после первого же ребута всё снова умрёт.
- Домена и обратного прокси тоже нет — снаружи сервис недоступен в принципе.
Вот вам и «не работает нигде». Корневая причина не в баге кода, а в том, что при переезде проект скопировали как папку с файлами, но не подняли как сервис. Это разные вещи. Приложение — это не только исходники; это ещё и процесс, который кто-то запускает и перезапускает, сеть, через которую до него достучаться, и TLS. Всё это при cp -r не переносится.
Когда диагноз сошёлся, я предложил не просто «запустить, чтобы работало», а сразу оформить это как штатный сервис на домене. Раз уж переезжаем — переезжаем правильно.
Решение, часть 1: DNS и сюрприз от Vercel
Договорились на поддомен llm-router.pashavin.ru — имя совпадает с именем сервиса, никакой двусмысленности. И тут вылез первый подводный камень, который стоит запомнить всем, у кого домен живёт на неймсерверах Vercel.
Пользователь сказал «DNS я прописал», но резолвинг показал, что llm-router.pashavin.ru указывает не на наш сервер, а на Vercel anycast (адреса вида 216.198.79.x). Причина: у pashavin.ru неймсерверы Vercel, и там настроен *.pashavin.ru wildcard, который тянет все поддомены на Vercel. Конкретные сервисы (deploy, supabase) имели явные A-записи на наш сервер и потому работали, а llm-router явной записи не имел — и его молча проглотил wildcard.
Лечится это только явной A-записью, которая перебивает wildcard. В панели Vercel → Domains → DNS добавляется запись:
Type: A
Name: llm-router
Value: <IP нашего прод-сервера>
TTL: 60
Мораль: wildcard-запись не спасает для сервисов, которые должны жить не там, куда смотрит wildcard. Каждый такой сервис нужен явной записью. Пока DNS расходился по резолверам, я параллельно поднимал всё остальное — эти шаги от DNS не зависят.
Забавный побочный эффект, который тоже полезно знать: публичные резолверы (1.1.1.1, 8.8.8.8) увидели правильный IP почти сразу, а вот локальный DNS-кеш на самом сервере ещё держал старый wildcard. Из-за этого curl с сервера возвращал вецелевскую страницу DEPLOYMENT_NOT_FOUND, хотя в браузере у пользователя всё уже открывалось. Проверять пришлось через curl --resolve, форсируя нужный IP напрямую, а потом флашить локальный кеш. Не перепутайте «сервис не работает» с «резолвер на этой машине отстал».
Решение, часть 2: зависимости, окружение и автозапуск
Дальше — прозаичная, но обязательная часть. npm install подтянул 131 пакет. Создал .env со свежесгенерированным ADMIN_SECRET (32 случайных байта — секрет, разумеется, в статью не попадает).
Самое ценное здесь — автозапуск через systemd. Именно его отсутствие и было настоящей причиной «умирает после ребута». Я оформил llm-router.service с enabled (стартует при загрузке) и Restart=always (поднимается, если процесс упал). Скелет юнита выглядит так:
[Unit]
Description=llm-router control plane
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/deploy/projects/llm-router
ExecStart=/usr/bin/node dist/server.js
Restart=always
EnvironmentFile=/home/deploy/projects/llm-router/.env
[Install]
WantedBy=multi-user.targetОдин нюанс, на котором легко споткнуться: изначально я закрутил ProtectHome, но пришлось его ослабить. Причина в природе самого сервиса — библиотеки OAuth (для Anthropic и Codex) периодически перезаписывают обновлённые refresh-токены обратно в файлы вроде ~/.codex/auth.json и credentials Claude. С жёстким ProtectHome сервис не смог бы обновлять свои же токены, и через какое-то время подписочный доступ бы протух. Мелочь, но именно из таких мелочей складывается «работало на демо, сдохло через сутки».
Решение, часть 3: HTTPS и Caddy
Снаружи всё закрывает Caddy — он же автоматически берёт Let's Encrypt-сертификат. Блок простой: проксируем на 127.0.0.1:3200, а на чувствительные разделы вешаем Basic Auth.
llm-router.pashavin.ru {
@protected path /dashboard* /admin*
basicauth @protected {
admin <bcrypt-хэш>
}
reverse_proxy 127.0.0.1:3200
log {
output file /var/log/caddy/llm-router.log
}
}
И тут вылез ещё один мелкий, но показательный баг: файл лога llm-router.log при первом caddy validate был создан под root, и потом рабочий процесс Caddy не мог в него писать. Пофиксил владельца файла. Урок на будущее: команды валидации/проверки, запущенные от другого пользователя, могут насоздавать артефактов не с теми правами — проверяйте owner логов и pid-файлов.
После старта проверил три вещи: /health отдаёт 200, /dashboard без авторизации возвращает 401, а с корректным логином/паролем — 200. Всё сошлось.
Неочевидная развилка: UI против машинного клиента
Казалось бы, готово. Но пользователь написал: «у меня не открылось UI» — и прислал JSON. И это было правильное поведение, которое ощущалось как баг.
Дело в том, что корневой роут / я сделал JSON-discovery — машиночитаемое описание сервиса для ИИ-клиентов: имя, версия, ссылка на /docs, схема авторизации, список эндпоинтов с моделями. Отлично для агента, который дёргает роутер программно. Но человек, открывший / в браузере, вместо интерфейса получал стену JSON.
Решение — content negotiation по заголовку Accept. Браузер присылает Accept: text/html; такой запрос редиректим на /dashboard. Любой другой клиент (агент без text/html в Accept) получает JSON-discovery как раньше. Одна развилка удовлетворяет оба сценария:
app.get('/', (req, reply) => {
const accept = req.headers.accept || ''
if (accept.includes('text/html')) {
return reply.redirect('/dashboard', 302)
}
return reply.send(discoveryDocument)
})Теперь человек, зайдя на корень, сразу попадает на форму логина и UI, а машина — на структурированное описание API. Мелкая деталь UX, но именно она превращает «сервис вроде поднят» в «сервисом реально удобно пользоваться».
Решение, часть 4: admin-API, которым рулят сами ИИ-агенты
Последний и самый интересный кусок родился из отдельной задачи: «надо, чтобы ИИ могли сами в моих проектах менять провайдеров и модели через API». То есть агент, работающий над проектом, должен уметь переключить, скажем, дефолтную модель или включить/выключить маршрут — без ручного лазания в конфиги.
Я дополнил уже существующий блок, защищённый x-admin-token (тот самый ADMIN_SECRET из .env), тремя JSON-эндпоинтами:
| Метод | Путь | Что делает |
|---|---|---|
GET | /admin/projects/:id | Текущий конфиг проекта + метаданные |
PUT | /admin/projects/:id | Заменить конфиг целиком, атомарно |
PATCH | /admin/projects/:id | Точечно: defaultRoute, enabled, отдельные маршруты |
Здесь вылезла тонкость авторизации. Раздел /admin/* у меня был под двумя слоями сразу: Caddy Basic Auth снаружи и токен-проверка внутри Fastify. Для человека это норм, но агент не умеет проходить Basic Auth браузерного типа. Поэтому для /admin я убрал внешний слой Basic Auth и оставил только токен-проверку на стороне приложения. Получилась чистая машинная авторизация: один заголовок x-admin-token, никаких браузерных диалогов. Проверил PATCH смоук-тестом на несуществующий ID — эндпоинт отвечает корректно, побочных эффектов нет.
Про инвариант «только подписки» — и почему я дважды сказал «нет»
Параллельно шла проработка ТЗ на транспорт для Gemini. И там дважды всплывал соблазн взять для MVP api_key из Google AI Studio — то есть платный pay-as-you-go доступ. Оба раза это отвергалось, и в итоге инвариант зафиксировали жёстко: в llm-router не должно быть API-ключей, работаем только через подписки — в этом и была вся задумка.
Логика простая. Если подписка Google (через Gemini CLI) уже оплачена, платить сверху за каждый токен через api_key — бессмысленно. Правильный флоу — подписочный OAuth-токен, ровно как для Claude Max и ChatGPT Plus, а не ключ из AI Studio. Я закрыл этот вопрос прямо в документации, чтобы будущие ИИ-агенты (и авторы ТЗ) не возвращались к нему по кругу. Формально инвариант и раньше был в KNOWLEDGE.md как Purpose, но не был продублирован явно в нужных местах — из-за чего и я, и автор оригинального ТЗ на секунду допустили api_key. Теперь — не допустят.
Результат
По итогу из «не работает нигде» получился нормальный продакшн-сервис:
llm-router.serviceпод systemd — автозапуск при загрузке,Restart=alwaysпри падении. Переживает ребут.- HTTPS
https://llm-router.pashavin.ruчерез Caddy с автосертификатом Let's Encrypt, прокси на127.0.0.1:3200. - Basic Auth только на
/dashboard/*(человеческий UI), токен-авторизация на/admin/*(машинный доступ). - Content negotiation на
/: браузер → UI, агент → JSON-discovery. - Admin-API из трёх эндпоинтов, которым ИИ-агенты сами меняют провайдеров и модели в проектах.
- Явная DNS A-запись, перебивающая Vercel-wildcard.
- Зафиксированный инвариант «только подписки, никаких api_key».
Проверено по всей цепочке: /health → 200, дашборд под Basic Auth, admin-эндпоинты отвечают на токен. Всё закоммичено и запушено.
Выводы
Первый и главный урок: приложение — это не папка с кодом. Самая частая ошибка при переезде — считать, что раз файлы скопированы, дело сделано. Но живой сервис состоит из четырёх слоёв: исходники, установленные зависимости, процесс-с-автозапуском и сетевая обвязка с TLS. При cp -r переносится только первый слой. Три остальных нужно поднимать заново — и именно их отсутствие даёт то самое обманчивое «код на месте, но не работает нигде». Хороший чек-лист переезда проверяет все четыре, а не один.
Второй урок: не путайте симптом с причиной, особенно в DNS. «Сервис не открывается» может означать что угодно — от упавшего процесса до отставшего локального резолвера. В этой истории curl с самого сервера показывал чужую страницу, хотя снаружи всё уже работало, потому что локальный кеш держал старую wildcard-запись. Всегда проверяйте резолвинг с нескольких точек и через --resolve, прежде чем лечить «мёртвый» сервис, который на самом деле жив. И помните про wildcard-неймсерверы: *.domain молча проглатывает поддомены, для которых нет явной записи.
Третий урок: автозапуск — это не опция, а часть определения «готово». Сервис, который надо поднимать руками, — это не развёрнутый сервис, а бомба замедленного действия до первого ребута. systemd с Restart=always и enable стоит настроить в первую же итерацию, а не «потом». И проверяйте изоляционные директивы вроде ProtectHome: слишком жёсткая песочница ломает сервисы, которым нужно писать в домашнюю директорию — как в нашем случае с обновлением OAuth-токенов.
Четвёртый урок: проектируйте под обоих потребителей — человека и машину. Современный сервис всё чаще дёргают не только люди в браузере, но и ИИ-агенты. Один и тот же корневой роут может обслуживать оба сценария через content negotiation, а машинный API имеет смысл авторизовать токеном, а не браузерным Basic Auth, который агент не пройдёт. И если у сервиса есть архитектурный инвариант — вроде «только подписки, никаких API-ключей» — фиксируйте его явно и в нескольких местах документации. Иначе следующий разработчик (или следующий ИИ) на голубом глазу нарушит его в первом же MVP, потому что так «проще для старта». Явно записанный принцип экономит недели хождения по одним и тем же граблям.
Полезные ссылки по технологиям из статьи: systemd unit files, Caddy reverse_proxy, Fastify routes, MDN про заголовок Accept и content negotiation, Vercel DNS records.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.