Сделал калькулятор налога для самозанятых на Laravel + Inertia + Vue с SSR и выложил на обычный shared-хостинг. Сам калькулятор написал за пару вечеров. Всё остальное время ушло на то, чтобы поисковики увидели сайт так же, как браузер.За два месяца: SSR-демон падал с EPERM, порт оказывался занят «через раз», главная месяц уходила в кэш пустой страницей, а проверка SSR в CI была зелёной, потому что не могла упасть в принципе. Плюс месяц блокировки хостером, о которой я не знал, и фильтр «малоценных страниц» от Яндекса.Разбираю грабли по порядку — может, кому-то это сэкономит месяц. Читать далее
Уровень сложностиСредний
Время на прочтение5 мин
Охват и читатели6.2K
Кейс
Как я два месяца отдавал Яндексу пустую страницу: SSR на Laravel + Inertia на shared-хостинге
Я сделал калькулятор налога для самозанятых. Ничего сложного: ввёл доход — получил налог. Сайт должен жить на поисковом трафике, поэтому SSR был обязательным требованием с первого дня. Стек — Laravel 12, Inertia, Vue 3, хостинг — обычный shared на Beget за пару сотен в месяц, без VPS и без root.
Сам калькулятор я написал за пару вечеров. Всё остальное время ушло на то, чтобы поисковики увидели его так же, как браузер. Ниже — грабли в том порядке, в каком я на них наступал. Если вы собираетесь делать SSR на дешёвом хостинге, возможно, это сэкономит вам месяц.
Грабли 1. Node на shared-хостинге — это не то же самое, что Node на VPSInertia 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 остаётся сиротой и держит порт. Теперь скрипт прогрева добивает и его.
На 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.
Параллельно сайт больше месяца отдавал роботам страницу «Сайт заблокирован хостинг-провайдером». С кодом 200. Включая robots.txt и sitemap.xml. Яндекс послушно индексировал заглушку.
Причина банальная — хостинг не был оплачен. Письмо ушло на почту, которую я не читал. Мониторинг смотрел на код ответа, а код был 200.
Теперь ежедневный отчёт в Telegram отдельно проверяет, что в ответе нет текста заглушки и есть <h1>. Мелочь, но месяц индексации она бы сэкономила.
Крон в панели хостинга в какой-то момент тихо перестал работать, и я перенёс расписание в GitHub Actions: прогрев, публикация новых статей, отчёт. Несколько cron в одном workflow с ветвлением по github.event.schedule.
Не делайте так. Планировщик GitHub задерживает запуски на часы (задание на 00:30 выполнилось в 05:00) и при этом передаёт строку того расписания, которое должно было сработать. В итоге прогрев отработал дважды, а публикация и отчёт — ни разу.
Сейчас всё расписание живёт в Laravel (routes/console.php), а на хостинге один крон раз в минуту дёргает schedule:run. Чтобы видеть, жив ли этот крон, планировщик каждую минуту трогает файл-пульс, а отчёт ругается, если файл старше десяти минут.
Когда техника наконец заработала, выяснилось, что этого мало. Сейчас у сайта 62 страницы, а в поиске Яндекса — 8. Ещё 20 помечены как «малоценные». В Google не сильно лучше: 11 страниц в индексе, остальные «обнаружены, но не проиндексированы».
Под фильтр попали в основном короткие статьи на 300–500 слов: страницы «самозанятый-репетитор», «самозанятый-фотограф» и так далее, сделанные по одной схеме. Для финансовой тематики Яндекс считает это недостаточным. Вывод, который я для себя сделал: на молодом домене не надо наращивать количество страниц. Лучше меньше, но длиннее, с расчётами и ссылками на закон.
Сейчас переписал четыре выкинутые статьи до 1000–1400 слов, отправил на переобход и жду, вернёт ли Яндекс их в поиск. Если интересно, чем закончится, — напишу.
Что я бы сделал иначе с самого началаПроверял бы SSR по контенту, который без SSR появиться не может: <h1>, текст страницы. Не по разметке в <head>.
Не клал бы в полностраничный кэш ответ без проверки, что он отрендерен.
На shared-хостинге — порт SSR вне диапазона эфемерных портов. Посмотреть его — одна команда.
Мониторил бы не код ответа, а содержимое.
Писал бы меньше страниц, но длиннее.
Сам калькулятор живёт здесь: https://nalog-samozanyatogo.ru — если вы самозанятый, можете проверить, сходится ли он с вашим начислением в «Мой налог». Вопросы по стеку и граблям — в комментарии, отвечу.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Полгода SEO с нуля после переезда с Tilda: органика ×20 и 12 граблей, на которые я наступил | 0 | 13.26 | 12-10-2026 |
| 2 | Блог на автопилоте: markdown в статику, выход статьи по дате и пинг в IndexNow только для новых страниц | 0 | 10.98 | 08-10-2026 |
| 3 | Как я научил Claude заказывать продукты в Яндекс Лавке (и что для этого пришлось отреверсить) | 0 | 10.18 | 22-07-2026 |
| 4 | Переменная в стилях. | -1 | 9.3 | 08-08-2026 |
| 5 | Как я научил Claude заказывать продукты в Яндекс Лавке (и что для этого пришлось отреверсить) | 0 | 10.18 | 22-07-2026 |
| 6 | Интеграция с Яндекс.Облаком и другими сервисами SEO | 0 | 5 | 07-07-2026 |
| 7 | Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 2 | 0 | 5 | 30-06-2026 |
| 8 | Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 1 | 0 | 5 | 29-06-2026 |
| 9 | Next.js, localStorage в App Router без hydration-ошибок | 0 | 7.17 | 30-07-2026 |