Как я два месяца отдавал Яндексу пустую страницу: 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 слов, отправил на переобход и жду, вернёт ли Яндекс их в поиск. Если интересно, чем закончится, — напишу.

Что я бы сделал иначе с самого начала

  1. Проверял бы SSR по контенту, который без SSR появиться не может: <h1>, текст страницы. Не по разметке в <head>.

  2. Не клал бы в полностраничный кэш ответ без проверки, что он отрендерен.

  3. На shared-хостинге — порт SSR вне диапазона эфемерных портов. Посмотреть его — одна команда.

  4. Мониторил бы не код ответа, а содержимое.

  5. Писал бы меньше страниц, но длиннее.

Сам калькулятор живёт здесь: https://nalog-samozanyatogo.ru — если вы самозанятый, можете проверить, сходится ли он с вашим начислением в «Мой налог». Вопросы по стеку и граблям — в комментарии, отвечу.

Комментарии (4)


  1. Krada
    07.10.2026 12:34

    Какую проблему пользователя решает это ПО?


    1. verng95
      07.10.2026 12:34

      Никаких, просто на вайбили и всё... Всё это считает приложение от налоговой.


  1. init0
    07.10.2026 12:34

    обычный shared на Beget за пару сотен в месяц, без VPS и без root

    возможно, это сэкономит вам месяц

    Причина банальная — хостинг не был оплачен.

    В вайбкодинг вы научились, а рефлексию LLM вам не завез


  1. Kumazo
    07.10.2026 12:34

    Зачем использовать все эти премудрости, для давольно таки простого сайта?