Waarom je Inertia SSR-daemon willekeurig crasht op een gedeelde server (en hoe poorten je redden)

Waarom je Inertia SSR-daemon willekeurig crasht op een gedeelde server (en hoe poorten je redden)

Draaien er meerdere Laravel Inertia-sites op één Forge-server? Dan kunnen hun SSR-daemons elkaar killen via dezelfde poort. Zo herken en fix je de restart-loop.

Focus-keywords: Inertia SSR poort, Laravel Forge meerdere sites, inertia:start-ssr restart loop, EADDRINUSE Node, server-side rendering Vue

Het symptoom: SSR werkt 1 op de 5 keer

Je hebt server-side rendering (SSR) netjes ingericht voor je Laravel + Inertia + Vue-site. Lokaal zie je keurig gerenderde HTML in je #app-div. Maar op productie is het een gok: de ene curl geeft ~45 KB volledig gerenderde HTML terug, de volgende ~31 KB met een lege <div id="app">. Inertia valt namelijk stil terug op client-side rendering als de SSR-server niet antwoordt: je krijgt geen error, alleen slechtere SEO.

Voor een site die het juist van organisch verkeer moet hebben, is dat een dure, onzichtbare bug. Google indexeert de lege variant en je blogartikelen blijven hangen in "Gecrawld – momenteel niet geïndexeerd".

De oorzaak: drie daemons, één poort

De echte oorzaak zat niet in de code, maar in de infrastructuur. Op de Forge-server draaiden drie Laravel-sites, en elke site startte zijn eigen Inertia SSR-daemon op dezelfde default-poort 13714.

Nu komt de valkuil. Het commando dat Forge gebruikt om SSR te starten:

php artisan inertia:start-ssr

roept eerst inertia:stop-ssr aan. En stop-ssr doet niets subtiels: het hit de /shutdown-route van wat er op die poort luistert, wat neerkomt op een process.exit().

Zie je al wat er gebeurt? Site A start, kilt "de oude daemon" op 13714, maar dat was de daemon van site B. Site B merkt dat hij weg is, herstart, en kilt daarmee site A. Een oneindige restart-loop, waarbij de logging alleen maar eindeloos toont:

Starting SSR server...
Starting SSR server...
Starting SSR server...

Geen stacktraces, geen EADDRINUSE, geen enkele hint dat er drie processen om één poort vechten. Puur daarom kostte dit uren.

De fix: geef elke site een unieke poort

De oplossing is simpel zodra je de oorzaak kent: elke Inertia-site op dezelfde server moet een eigen SSR-poort krijgen. De gedeelde default 13714 vermijd je volledig.

Maar er zit een tweede addertje onder het gras.

Waarom je .env de poort níét bepaalt

De intuïtieve aanpak is: zet INERTIA_SSR_URL=http://127.0.0.1:13716 in je .env en klaar. Dat werkt niet bij de standaard Forge SSR-toggle.

Reden: die toggle draait php artisan inertia:start-ssr onder een gecachte Laravel-config. De runtime-.env bereikt het Node-proces daardoor nooit. De poort waarop de daemon daadwerkelijk bindt, zit in de gecompileerde bundle (bootstrap/ssr/ssr.mjs), niet in je omgeving.

De robuuste oplossing is dus om de poort build-time in de bundle te bakken via Vite:

// resources/scripts/ssr.ts
const port = Number(
  new URL(
    import.meta.env.VITE_INERTIA_SSR_URL ?? "http://127.0.0.1:13716"
  ).port
);

En in je .env één bron van waarheid, gespiegeld naar Vite:

INERTIA_SSR_URL=http://127.0.0.1:13716
VITE_INERTIA_SSR_URL="${INERTIA_SSR_URL}"

Zowel phpdotenv (Laravel-kant) als Vite/dotenv-expand resolven de ${...}-nesting, dus je onderhoudt de poort op één plek. Omdat de waarde build-time wordt geresolved, moet je deploy-script npm run build (inclusief vite build --ssr) op de server draaien, waar de productie-.env staat.

Symptomen-decoder

Handig om bij de hand te houden als het misgaat:

Wat je zietWat het betekent
Log toont poort 13714 (de default)De fallback is gebruikt: variabele was undefined bij build, of verkeerde key (VITE_-prefix vergeten)
"started" gevolgd door restart-loopTwee daemons vechten om dezelfde poort
EADDRINUSEEen orphan- of dubbele daemon houdt de poort nog bezet
Lege <div id="app"> (~31 KB)Inertia is stilletjes teruggevallen op client-side rendering

Verifiëren dat SSR echt draait

Vertrouw niet op "het lijkt te werken". Test hard:

curl -s https://jouw-site.nl | grep -o '<div id="app"[^>]*>.\{0,150\}'

Een gevulde div (~45 KB HTML) betekent SSR. Een lege div (~31 KB) betekent CSR-fallback. Doe dit een paar keer achter elkaar — juist bij een poortconflict zie je de uitkomst wisselen.

De les

De bug leek een rendering-probleem, maar was een infrastructuurprobleem: gedeelde staat (een poort) tussen processen die niets van elkaars bestaan weten. De twee kernregels die je overhoudt:

  1. Elke Inertia SSR-site op een gedeelde server krijgt een unieke poort. De default is een gedeelde bron en dus een tijdbom.

  2. Weet wáár je configuratie leeft. Bij een gecachte config en een build-time bundle is je runtime-.env niet de bron van waarheid die je denkt dat hij is.

Poort wijzigen? Dan pas je drie dingen samen aan: INERTIA_SSR_URL, de fallback in ssr.ts, en vervolgens rebuild + deploy. Vergeet er één, en je bent zo weer terug in de restart-loop.