Ga naar inhoud
·13 min leestijd

De VPS-Setup Die Echt Werkt: Node.js, PM2, Nginx en Zero-Downtime Deploys

De exacte VPS-deploymentsetup die ik gebruik in productie — Ubuntu-hardening, PM2 cluster mode, Nginx reverse proxy, SSL en een deployscript dat me nog nooit in de steek heeft gelaten. Geen theorie, alleen wat werkt.

Delen:X / TwitterLinkedIn

Deze blog draait op een VPS van $10/maand. Niet Vercel, niet AWS, niet een Kubernetes-cluster beheerd door een team van zes. Een enkele Ubuntu-box met Nginx, PM2 en een bash-script dat deployt in minder dan 30 seconden.

Ik heb de andere paden geprobeerd. Ik heb Vercel gebruikt (geweldig totdat je cronjobs, persistente WebSockets of gewoon controle nodig hebt). Ik heb AWS gebruikt (geweldig als je ervan geniet de halve dag in IAM-policies door te brengen). Ik kom altijd terug bij een VPS.

Maar hier is het probleem: elke "deploy naar VPS"-tutorial op het internet stopt bij het happy path. Ze laten je zien hoe je Node.js installeert en node server.js draait en noemen het productie. Dan wordt je server SSH-gebruteforced, je process sterft om 3 uur 's nachts omdat niemand een process manager heeft opgezet, en je SSL-certificaat is drie maanden geleden verlopen.

Dit is de gids die ik had willen hebben. Alles hier is beproefd — precies deze setup serveert de pagina die je nu leest.

Begin Met Beveiliging, Niet Met Code#

Voordat je ook maar aan Node.js denkt, vergrendel de box. Verse VPS-instanties zijn doelwitten. Geautomatiseerde bots beginnen je SSH-poort te bestoken binnen minuten na het aanmaken.

Maak Een Niet-Root-Gebruiker Aan#

bash
adduser deploy
usermod -aG sudo deploy

Stel SSH-Sleutelauthenticatie In#

Op je lokale machine:

bash
ssh-copy-id deploy@your-server-ip

Schakel vervolgens wachtwoordauthenticatie volledig uit:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Als je dit overslaat, zie je binnen dagen duizenden mislukte inlogpogingen in je auth-logs. Dat is geen paranoia — dat is dinsdag op het openbare internet.

Firewall Met UFW#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Dat is het. Vier regels. Alleen SSH- en webverkeer komen erdoor.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Bewerk /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Drie mislukte SSH-pogingen en je bent een uur verbannen. Ik heb Fail2Ban honderden IP's op een enkele dag zien blokkeren. Het werkt.

Onbeheerde Beveiligingsupdates#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Je server installeert nu automatisch beveiligingspatches. Een ding minder om te vergeten.

Node.js: Gebruik NVM, Niet apt#

Ik zie dit in elke tutorial: sudo apt install nodejs. Doe het niet.

Ubuntu's pakketrepositories leveren verouderde Node.js-versies. Zelfs de NodeSource PPA loopt achter. En wanneer je moet wisselen tussen Node 20 en Node 22 voor verschillende projecten, zit je vast.

NVM lost dit op:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*

Nu verifieren:

bash
node -v  # v22.x.x of welke LTS dan ook actueel is
npm -v

De niet-voor-de-hand-liggende tip: wanneer je globale pakketten installeert met NVM (zoals PM2), zijn die gekoppeld aan die Node-versie. Als je van versie wisselt met nvm use, verdwijnen je globals. Stel je standaard in en blijf daarbij op de server:

bash
nvm alias default 22

Dit heeft me precies een keer gebeten. Een keer was genoeg.

PM2: De Process Manager Die Zijn Geld Waard Is#

PM2 is het verschil tussen "gedeployed" en "productie-klaar." Het handelt processbeheer, clustering, logrotatie, auto-herstart bij crashes en opstartscripts af. Gratis.

Installeren en Instellen#

bash
npm install -g pm2

De Ecosystem Config#

Start apps niet met CLI-vlaggen. Gebruik een ecosystem.config.js-bestand. Het is versiebeheerd, reproduceerbaar en zelfdocumenterend.

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "akousa",
      script: "node_modules/.bin/next",
      args: "start -p 3002",
      cwd: "/var/www/akousa.net",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3002,
      },
      // Sierlijke afsluiting
      kill_timeout: 5000,
      listen_timeout: 10000,
      wait_ready: false,
      // Logging
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      error_file: "/var/log/pm2/akousa-error.log",
      out_file: "/var/log/pm2/akousa-out.log",
      merge_logs: true,
      // Auto-herstart bij falen
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      // Niet watchen in productie
      watch: false,
    },
  ],
};

Laat me de keuzes uitleggen die ertoe doen:

instances: 2 in plaats van "max": Op een kleine VPS met 1-2 cores klinkt "max" slim, maar het spawnt processen die vechten om resources tijdens builds. Twee instanties geven je zero-downtime reloads terwijl er ruimte overblijft. Op een 4+ core machine, gebruik dan gerust "max".

exec_mode: "cluster": Dit is wat zero-downtime reloads mogelijk maakt. Zonder cluster mode is pm2 reload gewoon een fancy restart. Met cluster mode herstart PM2 instanties een voor een — je app gaat nooit volledig offline.

max_memory_restart: "500M": Heeft je Next.js-app een geheugenlek? PM2 herstart het voordat het je server OOM-killt. Dit heeft me meer dan eens gered van 2-uur-'s-nachts-alarmen.

kill_timeout: 5000: Geeft je app 5 seconden om lopende requests af te ronden voordat PM2 het geforceerd stopt. De standaard (1600ms) is te agressief voor apps met databaseverbindingen.

watch: false: Ik heb gezien dat mensen watch: true in productie laten staan. PM2 herstart dan de app elke keer dat een logbestand verandert. Je app komt in een herstartloop. Doe het niet.

Opstartscript#

Laat PM2 reboots overleven:

bash
pm2 startup systemd
# Kopieer en voer het commando uit dat het weergeeft
pm2 save

Dit genereert een systemd-service. Na een herstart van de server komt je app automatisch terug. Test het — herstart je server en verifieer. Ga er niet vanuit.

Logrotatie#

Logs vreten uiteindelijk je schijf op. Installeer de rotatiemodule:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

50MB maximaal per bestand, 7 geroteerde bestanden bewaren, de oude comprimeren. Zonder dit heb ik gezien dat /var/log een 25GB-schijf volschrijft in drie weken bij een app met gemiddeld verkeer.

Nginx: De Reverse Proxy Die Meer Doet Dan Je Denkt#

"Waarom niet gewoon Node.js direct blootstellen op poort 80?"

Omdat Nginx dingen afhandelt waar Node.js geen cycles aan zou moeten verspillen: SSL-terminatie, servering van statische bestanden, gzip-compressie, request-buffering, verbindingslimieten en sierlijke afhandeling van trage clients. Het is geschreven in C en speciaal hiervoor gebouwd.

Installeren#

bash
sudo apt install nginx -y

De Config#

nginx
# /etc/nginx/sites-available/akousa.net
 
upstream node_app {
    server 127.0.0.1:3002;
    keepalive 64;
}
 
server {
    listen 80;
    listen [::]:80;
    server_name akousa.net www.akousa.net;
 
    # Alle HTTP omleiden naar HTTPS
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name akousa.net www.akousa.net;
 
    # SSL (beheerd door Certbot — deze regels worden automatisch toegevoegd)
    ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # Beveiligingsheaders
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Gzip-compressie
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        application/wasm;
 
    # Proxy-instellingen
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
 
        # Headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # WebSocket-ondersteuning (voor het geval je het ooit nodig hebt)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # Timeouts — ruim maar niet oneindig
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
 
        # Buffering — laat Nginx trage clients afhandelen
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
 
    # Next.js statische assets — laat Nginx ze direct serveren
    location /_next/static/ {
        alias /var/www/akousa.net/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }
 
    # Publieke statische bestanden
    location /static/ {
        alias /var/www/akousa.net/public/static/;
        expires 30d;
        access_log off;
    }
 
    # Blokkeer toegang tot dotbestanden
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Activeer het:

bash
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Voer altijd nginx -t uit voordat je herlaadt. Ik heb ooit een kapotte config gepusht en de site platgelegd omdat ik de syntaxcontrole oversloeg. De vijf tekens nginx -t hadden me dertig minuten aan panisch debuggen bespaard.

Dingen die de meeste tutorials missen in deze config:

upstream-blok met keepalive 64: Nginx hergebruikt verbindingen naar je Node.js-backend in plaats van voor elk verzoek een nieuwe TCP-verbinding te openen. Dit maakt uit onder belasting.

proxy_buffering on: Nginx leest de volledige response van Node.js in het geheugen en stuurt deze dan naar de client op welke snelheid de client aankan. Zonder dit houdt een trage client op een 3G-verbinding je Node.js-worker bezet.

_next/static/ direct serveren: Dit zijn gehashte, onveranderlijke assets. Laat Nginx ze serveren vanaf schijf met een 365-dagen cache-header. Je Node.js-processen horen hier geen tijd aan te verspillen.

SSL in Vijf Minuten#

Let's Encrypt heeft SSL opgelost. Als je in 2026 nog betaalt voor certificaten, stop.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot zal om je e-mailadres vragen, de voorwaarden accepteren en automatisch je Nginx-config aanpassen om de SSL-directives op te nemen. Dat is het.

Verifieer Auto-Verlenging#

Certbot installeert een systemd-timer die twee keer per dag controleert en certificaten verlengt binnen 30 dagen voor de vervaldatum:

bash
sudo systemctl list-timers | grep certbot

Test of verlenging werkt:

bash
sudo certbot renew --dry-run

Als de dry run slaagt, hoef je nooit meer aan SSL te denken. Als het faalt, is het meestal omdat poort 80 geblokkeerd is (controleer je UFW-regels) of Nginx niet draait.

Een ding dat me verraste: als je Nginx instelt voordat je Certbot draait, zorg er dan voor dat je serverblok luistert op poort 80 zonder de HTTPS-redirect. Certbot moet poort 80 bereiken voor de HTTP-01 challenge. Nadat Certbot succesvol draait, voeg dan de redirect toe.

Het Deployscript#

Dit is het script dat elke keer draait als ik naar productie push. Geen CI/CD-platform, geen GitHub Actions. Gewoon SSH en bash.

bash
#!/bin/bash
# deploy.sh — zero-ish downtime deployment
 
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
 
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
 
log "=== Deploy gestart ==="
 
cd "$APP_DIR"
 
# Laatste code ophalen
log "Laatste wijzigingen ophalen..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# Dependencies installeren
log "Dependencies installeren..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# Bouwen
log "Applicatie bouwen..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "FOUT: Build mislukt. Deploy afgebroken."
    exit 1
fi
 
# PM2 herladen (zero-downtime in cluster mode)
log "PM2 herladen..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# Health check met herpogingen
log "Health check uitvoeren..."
for i in $(seq 1 $MAX_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
    if [ "$HTTP_CODE" = "200" ]; then
        log "Health check geslaagd (HTTP $HTTP_CODE)"
        log "=== Deploy succesvol afgerond ==="
        exit 0
    fi
    log "Health check poging $i/$MAX_RETRIES (HTTP $HTTP_CODE). Opnieuw proberen over ${RETRY_INTERVAL}s..."
    sleep $RETRY_INTERVAL
done
 
log "FOUT: Health check mislukt na $MAX_RETRIES pogingen"
log "Terugdraaien naar vorige PM2-status..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1

Maak het uitvoerbaar:

bash
chmod +x deploy.sh

Deploy vanaf je lokale machine:

bash
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"

Belangrijke beslissingen in dit script:

set -euo pipefail: Het script stopt onmiddellijk bij elke fout. Zonder dit gaat een mislukte npm install stilletjes verder naar de build-stap, en krijg je een cryptische fout die 20 minuten kost om te debuggen.

rm -rf .next voor het bouwen: Next.js heeft een build-cache die af en toe verouderde output produceert. Ik ben hier een keer door gebeten — een pagina toonde oude content ondanks dat de broncode was bijgewerkt. De build-directory verwijderen voegt misschien 15 seconden toe aan de build, maar garandeert verse output.

pm2 reload in plaats van pm2 restart: Dit is het zero-downtime-gedeelte. In cluster mode voert reload een rolling restart uit — het start nieuwe instanties met de bijgewerkte code, wacht tot ze klaar zijn en sluit dan sierlijk de oude af. Op geen enkel moment draaien er nul instanties.

Health check met herpogingen: Next.js heeft een paar seconden nodig om op te warmen na herstart. Het script wacht tot 30 seconden (10 herpogingen x 3 seconden) en controleert of de app reageert met HTTP 200. Als dat niet zo is, is er iets mis en moet je dat onmiddellijk weten — niet ontdekken via een gebruiker.

Rollback bij falen: Als de health check na alle herpogingen mislukt, herstart het script PM2 (dat de laatst opgeslagen status laadt). Het is geen perfecte rollback, maar het is beter dan de server in een kapotte staat achterlaten.

Als Dingen Kapotgaan om 2 Uur 's Nachts#

Dit is wat ik daadwerkelijk heb gedebugged op precies deze setup:

"De site is down"#

Eerste commando's om uit te voeren:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

Negen van de tien keer vertelt pm2 logs je direct wat er is gebeurd. Een ontbrekende omgevingsvariabele, een mislukte databaseverbinding, of een onafgevangen promise rejection.

"Geheugen blijft groeien"#

bash
pm2 monit

Dit geeft je een live dashboard van CPU en geheugen per proces. Als het geheugen gestaag stijgt zonder te stabiliseren, heb je een lek. De max_memory_restart-instelling in je ecosystem config is je vangnet — PM2 herstart het proces voordat het de server neerlegt.

Voor dieper onderzoek:

bash
pm2 describe akousa

Dit toont uptime, herstartaantallen en geheugenmomentopnamen. Als je 47 herstarts ziet in de laatste 24 uur, dan is dat je aanwijzing.

"SSL-certificaat verlopen"#

bash
sudo certbot certificates

Toont alle certificaten met hun vervaldatums. Als automatische verlenging is mislukt:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"Schijfruimte is vol"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 flush wist alle PM2-logbestanden onmiddellijk. Als je geen logrotatie hebt ingesteld (ik heb het je gezegd), is dit waar je de pijn voelt.

Het Commando Dat Ik Elke Ochtend Uitvoer#

bash
ssh deploy@akousa.net "pm2 status && df -h / && uptime"

Drie dingen in een regel: draaien mijn processen, is mijn schijf in orde, is de server overbelast. Duurt twee seconden. Vangt problemen op voordat gebruikers dat doen.

Wat De Meeste Gidsen Je Niet Vertellen#

Je build-stap is je grootste kwetsbaarheid. Op een VPS met 1GB RAM kan npm run build voor een Next.js-app 800MB+ geheugen consumeren. Als PM2 je app in twee instanties draait tijdens de build, ga je OOM. Oplossingen: gebruik een swap file (minimaal 2GB), of stop de app tijdens builds en accepteer een paar seconden downtime. Ik gebruik swap.

bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

--legacy-peer-deps in je install-commando is een code smell, geen oplossing. Ik gebruik het omdat sommige pakketten in mijn dependency tree hun peer dependency-ranges niet hebben bijgewerkt. Om de paar maanden probeer ik het te verwijderen. Op een dag zal het werken. Tot die tijd ship ik.

Test je deployscript vanaf nul. Clone je repository op een verse server en voer elke stap handmatig uit. Het aantal "werkt op mijn machine"-problemen dat schuilt in deployscripts is genant. Ik vond er drie in de mijne toen ik dit deed — ontbrekende globale pakketten, verkeerde bestandsrechten, en een pad dat alleen bestond door een eerdere handmatige setup.

Zet het IP-adres van je server in je SSH-config. Stop met het typen van IP-adressen:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Nu is ssh akousa alles wat je nodig hebt. Kleine dingen stapelen zich op.

De Volledige Checklist#

Voordat je het af noemt:

  • Niet-root-gebruiker met sudo-toegang
  • Alleen SSH-sleutelauthenticatie, wachtwoordauthenticatie uitgeschakeld
  • UFW ingeschakeld met alleen noodzakelijke poorten open
  • Fail2Ban beschermt SSH
  • Onbeheerde beveiligingsupdates ingeschakeld
  • Node.js geinstalleerd via NVM
  • PM2 draait je app in cluster mode
  • PM2-opstartscript geconfigureerd (overleeft reboot)
  • PM2-logrotatie geinstalleerd
  • Nginx reverse proxy met juiste headers
  • SSL via Let's Encrypt met automatische verlenging
  • Deployscript met health checks
  • Swap file geconfigureerd (voor build-ruimte)
  • Getest: herstart de server en verifieer dat alles terugkomt

Dat laatste punt is wat mensen overslaan. Wees niet die persoon. Herstart de server, wacht 60 seconden en controleer of je app live is. Als dat niet zo is, zijn je opstartscripts verkeerd geconfigureerd en je ontdekt dat op het slechtst mogelijke moment.

Is Dit "Enterprise-Grade"?#

Nee. En dat is het punt.

Deze setup serveert deze blog betrouwbaar voor minder dan $10/maand. Het is gedeployed in 30 seconden met een enkel commando. Ik begrijp elk onderdeel ervan. Als er iets kapotgaat, weet ik precies waar ik moet kijken.

Zou ik Docker kunnen gebruiken? Zeker. Zou ik Kubernetes kunnen gebruiken? Technisch gezien. Zou ik een volledige CI/CD-pipeline kunnen opzetten met staging-omgevingen en canary deployments? Absoluut.

Maar ik heb geleerd dat de beste infrastructuur degene is die je daadwerkelijk begrijpt, om 2 uur 's nachts kunt debuggen, en niet meer kost dan het project oplevert. Voor een persoonlijke site, een SaaS MVP of een kleine startup — dit is die setup.

Ship eerst. Schaal wanneer het nodig is. En altijd, altijd, test je deployscript op een verse server.