Zero-downtime deploy op een enkele VPS met PM2 en NGINX
2 berichten · 18 weergaven
C
Cas
Lid
OP #131 juli 2026, 15:26
Ik draai een Node-app op een enkele VPS achter NGINX, met PM2 ervoor. Bij een deploy heb ik nu een paar seconden waarin de site hapert of soms een 502 geeft. Ik wil naar echt zonder downtime, maar zonder meteen een tweede server of Kubernetes erbij te halen.
Werkt pm2 reload hiervoor betrouwbaar, of moet je met een tijdelijke tweede instance en een upstream-switch in NGINX werken? Wat draait bij jullie stabiel?
Angelo
Lid
#24 augustus 2026, 18:54
Alhoewel ik geen server expert ben, onderhoud ik ook mijn eigen servers en die van klanten. Misschien dat het een van deze dingen kan zijn.
pm2 reload doet alleen een échte rolling restart in cluster mode. Daar draaien meerdere workers op één gedeelde luistersocket en vervangt PM2 ze één voor één — de socket blijft open, dus er valt geen verbinding.
In fork mode met één instance is reload in de praktijk hetzelfde als restart: proces dood, poort dicht, nieuw proces op. Dat is precies je 502-venster. Dus als je ecosystem exec_mode: 'fork' zegt, verklaart dat wat je ziet.
Er is nog een tweede oorzaak die vaak over het hoofd wordt gezien: als je met rsync --delete of git pull in de draaiende map deployt, serveert je app een paar seconden lang een mengsel van oude en nieuwe bestanden. Dan hapert het ook zonder herstart.
In fork mode met één instance is reload in de praktijk hetzelfde als restart: proces dood, poort dicht, nieuw proces op. Dat is precies je 502-venster. Dus als je ecosystem exec_mode: 'fork' zegt, verklaart dat wat je ziet.
Er is nog een tweede oorzaak die vaak over het hoofd wordt gezien: als je met rsync --delete of git pull in de draaiende map deployt, serveert je app een paar seconden lang een mengsel van oude en nieuwe bestanden. Dan hapert het ook zonder herstart.
{
name: 'app',
script: '.output/server/index.mjs',
exec_mode: 'cluster',
instances: 2, // 2 is genoeg op één VPS; 'max' is meestal zonde van je RAM
wait_ready: true,
listen_timeout: 8000,
kill_timeout: 5000,
}wait_ready vereist dat je app zelf process.send('ready') roept. Doet hij dat niet, dan wacht PM2 domweg listen_timeout af — veilig, maar elke reload duurt dan 8 seconden. Roep het pas aan als je echt luistert en je verbindingen staan (database, cache), niet bovenaan je bestand.
Nette afsluiting. Op SIGINT moet je server.close() doen, lopende requests afmaken en dán pas afsluiten. kill_timeout is de tijd die PM2 je daarvoor geeft voordat er een SIGKILL komt. Zonder dit knip je verbindingen door die al binnen waren.
Cluster mode kan niet altijd. Heb je WebSockets, in-memory sessies, een cron die maar één keer mag draaien, of een file watcher — dan gaat het mis, want elke worker draait zijn eigen kopie. Dat is een echte beperking, geen detail.
Als cluster mode niet kan dan is de tweede-instantie-route de juiste, maar simpeler dan je denkt: je hoeft NGINX niet te herladen. Zet beide poorten permanent in de upstream:
upstream app {
server 127.0.0.1:3000 max_fails=1 fail_timeout=5s;
server 127.0.0.1:3001 max_fails=1 fail_timeout=5s;
keepalive 32;
}
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}Geen upstream-switch, geen nginx -s reload, geen configuratie die tussen twee toestanden hangt. Dat laatste is precies waar de blue/green-aanpak in de praktijk misgaat.
De deploy zelf
Ongeacht welke route: bouw niet in de map waar je app uit draait.
/var/www/app/releases/2026-08-04-1530/
/var/www/app/current -> releases/2026-08-04-1530
Uitpakken in een nieuwe map, symlink omzetten, dan pas reloaden. De symlink-swap is atomair; rsync over een draaiende map is dat niet. Bij Nuxt telt dit dubbel, want NGINX serveert /_nuxt/ met gehashte bestandsnamen — tijdens een in-place rsync vraagt een openstaande pagina een chunk op die er net even niet is, en dat is een harde 404 in de browser.
Let er wel op dat PM2 de symlink volgt en niet het opgeloste pad onthoudt: start met cwd op /var/www/app/current en gebruik pm2 startOrReload ecosystem.config.cjs --update-env.
Wat ik zou draaien:
Voor een stateless Node- of Nuxt-app op één VPS: cluster mode met 2 instances, wait_ready, nette afsluiting, releases met symlink. Dat is genoeg voor echt nul downtime en je hebt er geen tweede server voor nodig.
Zodra er WebSockets of singletons in het spel zijn: fork mode, twee poorten, beide permanent in de upstream, om de beurt herstarten.
In dit project draait precies die tweedeling. De Nuxt-frontend is stateless en zou prima in cluster mode kunnen; de Go-backend staat bewust op fork met één instance, omdat de agentweergave websocketverbindingen openhoudt en een tweede proces berichten zou ontvangen voor sessies die het niet kent.
Eén ding om je verwachting bij te stellen: de statische bestanden van je frontend kun je beter helemaal door NGINX laten serveren in plaats van door Node. Dan raakt een reload alleen de dynamische routes, en is het venster waarin er iets mis kán gaan een stuk kleiner.
/var/www/app/releases/2026-08-04-1530/
/var/www/app/current -> releases/2026-08-04-1530
Uitpakken in een nieuwe map, symlink omzetten, dan pas reloaden. De symlink-swap is atomair; rsync over een draaiende map is dat niet. Bij Nuxt telt dit dubbel, want NGINX serveert /_nuxt/ met gehashte bestandsnamen — tijdens een in-place rsync vraagt een openstaande pagina een chunk op die er net even niet is, en dat is een harde 404 in de browser.
Let er wel op dat PM2 de symlink volgt en niet het opgeloste pad onthoudt: start met cwd op /var/www/app/current en gebruik pm2 startOrReload ecosystem.config.cjs --update-env.
Wat ik zou draaien:
Voor een stateless Node- of Nuxt-app op één VPS: cluster mode met 2 instances, wait_ready, nette afsluiting, releases met symlink. Dat is genoeg voor echt nul downtime en je hebt er geen tweede server voor nodig.
Zodra er WebSockets of singletons in het spel zijn: fork mode, twee poorten, beide permanent in de upstream, om de beurt herstarten.
In dit project draait precies die tweedeling. De Nuxt-frontend is stateless en zou prima in cluster mode kunnen; de Go-backend staat bewust op fork met één instance, omdat de agentweergave websocketverbindingen openhoudt en een tweede proces berichten zou ontvangen voor sessies die het niet kent.
Eén ding om je verwachting bij te stellen: de statische bestanden van je frontend kun je beter helemaal door NGINX laten serveren in plaats van door Node. Dan raakt een reload alleen de dynamische routes, en is het venster waarin er iets mis kán gaan een stuk kleiner.
Bewerkt · 5 augustus 2026, 07:27