Как я два месяца отдавал Яндексу пустую страницу: SSR на Laravel + Inertia на shared-хостинге
Я сделал калькулятор налога для самозанятых. Ничего сложного: ввёл доход — получил налог. Сайт должен жить на поисковом трафике, поэтому SSR был обязательным требованием с первого дня. Стек — Laravel 12, Inertia, Vue 3, хостинг — обычный shared на Beget за пару сотен в месяц, без VPS и без root.
Сам калькулятор я написал за пару вечеров. Всё остальное время ушло на то, чтобы поисковики увидели его так же, как браузер. Ниже — грабли в том порядке, в каком я на них наступал. Если вы собираетесь делать SSR на дешёвом хостинге, возможно, это сэкономит вам месяц.
Грабли 1. Node на shared-хостинге — это не то же самое, что Node на VPS
Inertia SSR — это отдельный Node-процесс, который принимает JSON страницы и отдаёт HTML. Laravel ходит к нему по HTTP. На локальной машине всё запускается одной командой php artisan inertia:start-ssr.
На Beget первым делом упал кластерный режим: shared-хостинг не даёт раздавать сокет воркерам, получаем EPERM. Лечится cluster: false.
Дальше упал уже сам сервер. Штатный createServer из @inertiajs/vue3/server вызывает listen(port) без хоста, то есть слушает все интерфейсы. Хостинг это запрещает — снова EPERM. Пришлось написать свой маленький HTTP-сервер с тем же протоколом (/health, /render, /shutdown), но с явным 127.0.0.1:
const SSR_HOST = '127.0.0.1'; const SSR_PORT = Number(process.env.INERTIA_SSR_PORT) || 65217; createServer(async (request, response) => { switch (request.url) { case '/health': return json(response, { status: 'OK', timestamp: Date.now() }); case '/render': return json(response, await render(JSON.parse(await readBody(request)))); case '/shutdown': json(response, { status: 'OK' }); return process.exit(); } }).listen(SSR_PORT, SSR_HOST);
Laravel о подмене не знает: протокол тот же.
Грабли 2. Порт, который занят «через раз»
Демон стал запускаться — но не всегда. Иногда EADDRINUSE, хотя по ps порт свободен. Сменил порт с дефолтного 13714 на 24873 — то же самое. Сменил ещё раз — то же самое.
Разгадка нашлась в /proc/sys/net/ipv4/ip_local_port_range: на сервере диапазон эфемерных портов 5001–65000, почти всё пространство. Исходящие соединения всех соседей по серверу берут порты оттуда, и в какой-то момент мой «случайный свободный» порт оказывается занят чужим curl. Решение — порт вне диапазона: 65217. С тех пор ни одного падения.
Ещё одна мелочь из той же серии: artisan inertia:start-ssr запускает node дочерним процессом. Если убить только PHP-обёртку, node остаётся сиротой и держит порт. Теперь скрипт прогрева добивает и его.
Грабли 3. Демона нельзя держать постоянно
На shared-хостинге долгоживущий процесс рано или поздно прибьют. Поэтому архитектура такая: страницы отдаются из полностраничного кэша (spatie/laravel-responsecache), а SSR-демон поднимается только на время прогрева кэша — после деплоя, после правки контента в админке и раз в несколько часов по крону. Прогрел все страницы — погасил.
Звучит разумно. Именно здесь я и устроил себе главную проблему.
Грабли 4. Месяц пустой главной
Через месяц после запуска я открыл исходный код главной так, как его видит робот. Там было:
<div id="app" data-page="{...}"></div>
88 символов видимого текста. Ни одного <h1>. Пустая страница.
Что произошло: любой запрос, попавший между прогревами в промах кэша, рендерился без SSR — демона-то нет. Inertia честно отдавала клиентскую версию, Vue в браузере всё дорисовывал, человек ничего не замечал. А responsecache честно клал этот ответ в кэш — на неделю.
Но у меня же была проверка SSR в CI! Она звучала так: curl / | grep FAQPage. Разметка FAQPage лежит в <head> в JSON-LD, и её рендерит Blade — с SSR или без. Проверка не могла упасть никогда. Самый дорогой тест в моей жизни — тот, который всегда зелёный.
Починка в два шага. Первый — кэш отказывается запоминать страницу без SSR:
class SsrAwareCacheProfile extends CacheAllSuccessfulGetRequests { public function shouldCacheResponse(Response $response): bool { return parent::shouldCacheResponse($response) && ! preg_match('~<div id="app" data-page="[^"]*"\s*></div>~', (string) $response->getContent()); } }
Пустой ответ пользователь всё равно получает — Vue его дорисует, — но в кэш он не попадает, и следующий прогрев положит туда нормальный HTML.
Второй — критерий «SSR работает» теперь <h1> в ответе, а не что-то, что может отрендериться и без SSR. Его проверяют и скрипт прогрева, и смок-тесты в CI.
Грабли 5. Сайт был заблокирован, а я не знал
Параллельно сайт больше месяца отдавал роботам страницу «Сайт заблокирован хостинг-провайдером». С кодом 200. Включая robots.txt и sitemap.xml. Яндекс послушно индексировал заглушку.
Причина банальная — хостинг не был оплачен. Письмо ушло на почту, которую я не читал. Мониторинг смотрел на код ответа, а код был 200.
Теперь ежедневный отчёт в Telegram отдельно проверяет, что в ответе нет текста заглушки и есть <h1>. Мелочь, но месяц индексации она бы сэкономила.
Грабли 6. Расписание на GitHub Actions
Крон в панели хостинга в какой-то момент тихо перестал работать, и я перенёс расписание в GitHub Actions: прогрев, публикация новых статей, отчёт. Несколько cron в одном workflow с ветвлением по github.event.schedule.
Не делайте так. Планировщик GitHub задерживает запуски на часы (задание на 00:30 выполнилось в 05:00) и при этом передаёт строку того расписания, которое должно было сработать. В итоге прогрев отработал дважды, а публикация и отчёт — ни разу.
Сейчас всё расписание живёт в Laravel (routes/console.php), а на хостинге один крон раз в минуту дёргает schedule:run. Чтобы видеть, жив ли этот крон, планировщик каждую минуту трогает файл-пульс, а отчёт ругается, если файл старше десяти минут.
Грабли 7. Яндекс: «малоценная или маловостребованная страница»
Когда техника наконец заработала, выяснилось, что этого мало. Сейчас у сайта 62 страницы, а в поиске Яндекса — 8. Ещё 20 помечены как «малоценные». В Google не сильно лучше: 11 страниц в индексе, остальные «обнаружены, но не проиндексированы».
Под фильтр попали в основном короткие статьи на 300–500 слов: страницы «самозанятый-репетитор», «самозанятый-фотограф» и так далее, сделанные по одной схеме. Для финансовой тематики Яндекс считает это недостаточным. Вывод, который я для себя сделал: на молодом домене не надо наращивать количество страниц. Лучше меньше, но длиннее, с расчётами и ссылками на закон.
Сейчас переписал четыре выкинутые статьи до 1000–1400 слов, отправил на переобход и жду, вернёт ли Яндекс их в поиск. Если интересно, чем закончится, — напишу.
Что я бы сделал иначе с самого начала
Проверял бы SSR по контенту, который без SSR появиться не может:
<h1>, текст страницы. Не по разметке в<head>.Не клал бы в полностраничный кэш ответ без проверки, что он отрендерен.
На shared-хостинге — порт SSR вне диапазона эфемерных портов. Посмотреть его — одна команда.
Мониторил бы не код ответа, а содержимое.
Писал бы меньше страниц, но длиннее.
Сам калькулятор живёт здесь: https://nalog-samozanyatogo.ru — если вы самозанятый, можете проверить, сходится ли он с вашим начислением в «Мой налог». Вопросы по стеку и граблям — в комментарии, отвечу.
Комментарии (4)

init0
07.10.2026 12:34обычный shared на Beget за пару сотен в месяц, без VPS и без root
возможно, это сэкономит вам месяц
Причина банальная — хостинг не был оплачен.
В вайбкодинг вы научились, а рефлексию LLM вам не завез
Krada
Какую проблему пользователя решает это ПО?
verng95
Никаких, просто на вайбили и всё... Всё это считает приложение от налоговой.