SSD Nodes Learn 🎉 VPS vanaf $5.50/mnd
Gidsen Matt ConnorDoor Matt Connor

Headless browser draaien op een VPS voor AI agents

Voorkom crashes van Chromium op uw VPS door de juiste configuratie voor /dev/shm, sandbox flags en ontbrekende fonts. Leer hoe u processen beheert en uw agent stabiel houdt.

Wat u draait

Een headless browser op een VPS is Chromium zonder venster, aangestuurd door uw code in plaats van door een persoon. Op een server is het een langlopende procesboom waar uw agent via een lokale socket mee communiceert. De installatie vereist slechts één commando. Het eigenlijke werk volgt daarna. U begrenst wat de browser van de machine mag verbruiken en u houdt het controle-eindpunt buiten het publieke internet.

Deze handleiding gaat ervan uit dat de keuze voor de tool al is gemaakt en dat u deze nu moet beheren. Als u nog bezig bent met het vergelijken van crawlers en extractors, begin dan bij de self-hosted Firecrawl-alternatieven en keer daarna terug. Alles hieronder maakt gebruik van Playwright's Chromium, omdat Playwright zijn eigen browserbuild en dependency-installer meelevert. Hierdoor werken dezelfde commando's op een kale Ubuntu VPS en binnen een container. De versies zijn actueel per augustus 2026.

Chromium installeren zonder afhankelijkheden te raden

npm i -D playwright@1.62.0
npx playwright install --with-deps chromium

--with-deps voert apt uit voor de gedeelde bibliotheken en lettertypen die Chromium nodig heeft, en vraagt om root-rechten wanneer dit nodig is. De browser-build zelf wordt gedownload naar ~/.cache/ms-playwright voor de gebruiker die het commando uitvoerde. Dit is van belang op een server, omdat de service-gebruiker meestal niet de gebruiker is waarmee u inlogt. Installeer de systeempakketten eenmalig als beheerder met sudo npx playwright install-deps chromium en stel vervolgens PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers in, zowel in het installatiecommando als in de service-unit, zodat één kopie wordt gedeeld. Een service die zijn browser niet kan vinden, faalt bij het opstarten met een melding die het doorzochte pad vermeldt.

Zet de Playwright-versie vast. Elke release is gekoppeld aan één specifieke browser-build, dus een niet-vastgezette npm update kan de browser onder een draaiende service vervangen. Playwright 1.62 is de huidige versie per augustus 2026.

Er bestaan twee Chromium-builds en dit zijn niet dezelfde programma's. De standaard download is de headless shell, een kleiner binair bestand dat alleen headless draait, en npx playwright install --with-deps --only-shell installeert alleen deze versie. De volledige browser krijgt u via het chromium-kanaal, wat de browserdocumentatie van Playwright omschrijft als "de echte Chrome-browser, en dus authentieker, betrouwbaarder en met meer functies". Gebruik de shell voor bulkverwerking. Gebruik de volledige browser wanneer een site afwijkend gedrag vertoont en u de oorzaak moet achterhalen.

Waarom een headless browser crasht in een container

Docker geeft elke container een /dev/shm van 64 MB. De documentatie van Docker is expliciet: "Als u de grootte volledig weglaat, gebruikt het systeem 64m". Chromium verplaatst gerenderde inhoud tussen zijn processen via dat gedeelde geheugengebied, waardoor één zware pagina dit kan vullen. De renderer stopt vervolgens en uw client rapporteert een gecrasht doel, op een pagina die op uw laptop prima werkt. Controleer de grootte vanuit de container voordat u wijzigingen aanbrengt.

df -h /dev/shm

Er zijn twee echte oplossingen en dit zijn alternatieven, geen combinatie. --ipc=host plaatst de container in de IPC-namespace van de host, waardoor deze de /dev/shm van de host gebruikt, wat normaal gesproken de helft van het RAM-geheugen is. De Docker-handleiding van Playwright raadt dit aan, omdat "Chromium zonder dit geheugen tekort kan komen en crashen". De keerzijde is dat u de IPC-isolatie tussen de container en de host verliest. --shm-size=1g behoudt de private namespace en vergroot simpelweg de mount.

docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash

De vlag --disable-dev-shm-usage is het antwoord dat u in de meeste zoekresultaten zult vinden, en deze doet iets anders: het verplaatst die bestanden uit /dev/shm naar een tijdelijke map. Als /tmp op de schijf staat, heeft u een crash ingeruild voor tragere rendering en schijfschrijfacties. Als /tmp een tmpfs is, staan de gegevens weer in het RAM zonder enige limiet, wat een manier is waarop een browser een kleine VPS kan uitputten. Configureer /dev/shm liever op de juiste grootte.

Wat --no-sandbox daadwerkelijk kost

Chromium isoleert elke renderer in een sandbox die is gebouwd op Linux user namespaces. Die sandbox vormt de grens tussen een kwaadaardige pagina en uw server. Wanneer deze niet kan starten, weigert Chromium te draaien en bevat het logbestand een regel zoals deze:

Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted

Het gebruikelijke advies is --no-sandbox. De eigen beveiligingsdocumentatie van Chromium is onomwonden over de prijs: de vlag "schakelt kritieke beveiligingsfuncties van Chromium uit en mag nooit worden gebruikt bij het browsen op het open web". Een agent die links volgt, is per definitie aan het browsen op het open web. Zoek naar de werkelijke oorzaak.

Twee oorzaken dekken bijna elk geval. Het draaien van de browser als root schakelt de sandbox uit, omdat deze geen privileges kan laten vallen die het al bezit; daarom levert de image van Playwright een gewone gebruiker genaamd pwuser mee. Op Ubuntu 24.04 en later beperkt AppArmor niet-geprivilegieerde user namespaces, en een Chromium-binary op een pad dat niet door een meegeleverd profiel wordt gedekt, wordt geweigerd. De download van Playwright onder ~/.cache/ms-playwright is precies zo'n pad. Controleer beide:

id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_create

Een 1 vanuit de sysctl plus een kernelregel die apparmor="DENIED" operation="userns_create" bevat, bevestigt de tweede oorzaak. Sta die ene binary toe in /etc/apparmor.d/pw-chromium, waardoor de beperking voor al het andere op de machine behouden blijft:

abi <abi/4.0>,
include <tunables/global>

profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
  userns,
}

Laad deze met sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Het pad bevat de browserrevisie, dus dit verandert bij elke Playwright-upgrade. De bovenstaande globs overleven dat. Een profiel dat is geschreven voor één exact pad stopt stilletjes met matchen, en de browser begint weer te falen na een update die ongerelateerd leek.

Waarom screenshots leeg zijn of vol blokjes staan

Een lege screenshot, of een screenshot vol lege rechthoeken, is meestal een lettertypeprobleem in plaats van een renderingfout. install-deps haalt een werkende basis op: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic voor Japans, fonts-wqy-zenhei voor Chinees, fonts-tlwg-loma-otf voor Thais. Er zit geen Noto CJK in die set, waardoor Koreaans en diverse andere schriften terugvallen op wat fontconfig kan vinden. Vraag het aan fontconfig in plaats van te gokken:

fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -l

Als een taal die voor u van belang is, wordt omgezet naar unifont of naar een fallback zonder echte glyphs, installeer dan fonts-noto-core en fonts-noto-cjk en voer de controle opnieuw uit. Fontconfig slaat resultaten op in een cache, dus herstart de browser na het installeren van lettertypes. Een gestripte image zonder enige lettertypes logt Fontconfig error: Cannot load default config file bij het opstarten en rendert elke pagina als leeg.

Locale en tijdzone staan los van lettertypes; zij bepalen wat er op de pagina staat, niet alleen hoe het eruitziet. Een container heeft normaal gesproken LANG niet ingesteld en TZ op UTC, waardoor sites Engels serveren en UTC-tijdstempels afdrukken. Uw agent rapporteert dan tijden die niet overeenkomen met wat een persoon in dat land ziet. Stel deze in per browsercontext in plaats van per machine, zodat één browser taken voor verschillende regio's kan uitvoeren.

const context = await browser.newContext({
  locale: 'en-GB',
  timezoneId: 'Europe/Paris',
});

Waarom gelekte browserprocessen het systeem dwingen tot swappen

Twee verschillende problemen delen de naam "zombie". Een echte zombie is een beëindigd proces waarvan de ouder nooit wait() heeft aangeroepen. Het bevat enkel een PID-vermelding en verbruikt verder geen geheugen. U krijgt hiermee te maken wanneer de browser als PID 1 in een container draait, omdat PID 1 geen standaard reaper heeft. De --init-vlag van Docker lost dit precies op door een kleine init te draaien die "signalen doorstuurt en processen opruimt". In Compose is dit init: true.

Het lek dat uw systeem daadwerkelijk dwingt tot swappen is anders: actieve Chromium-processen die niemand heeft afgesloten. Dit gebeurt wanneer een taak een fout genereert tussen newContext() en close(), of wanneer het aansturende script wordt beëindigd en zijn browserboom als wees achterlaat. De ergste variant is code die voor elk verzoek een nieuwe browser start. Tel ze:

pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20

Dat aantal zou tussen taken door moeten terugkeren naar de rustwaarde. Als het aantal gedurende de dag oploopt, ligt de oplossing in uw code en niet in de opstartvlaggen: sluit de context in een finally-blok, sluit de browser bij SIGTERM, en recycle de browser na een vast aantal taken in plaats van er een maand lang een te laten draaien. Onder systemd beëindigt een stop of herstart alles in de cgroup van de unit, waardoor sudo systemctl restart browser.service een betrouwbare reset is. Een browser die handmatig in een terminal multiplexer is gestart, heeft die garantie niet en de weesprocessen overleven de sessie.

Hoeveel RAM heeft één browsercontext nodig

Stel de vraag nauwkeurig, want "één browser" is niet één proces. Chromium voert een browserproces, een GPU-proces, hulpprocessen en één renderer-proces per site uit. Site-isolatie zorgt er bovendien voor dat cross-site iframes hun eigen renderer krijgen. Een BrowserContext is een afzonderlijke cookie-opslag en opslagruimte binnen diezelfde boomstructuur, dus een tweede context is goedkoop. Een tweede pagina is dat niet, omdat deze renderer-processen start, en een pagina met veel advertenties start er zelfs meerdere.

Het getal dat u moet meten is het piekgeheugen voor de gehele boomstructuur onder uw eigen werklast. Een cijfer uit de blog van iemand anders is hier waardeloos, omdat de pagina's die uw agent opent het antwoord bepalen. Meet op de machine die u gaat gebruiken, voor de sites die u gaat bezoeken:

sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probe

Op Ubuntu 24.04 rapporteert de Memory:-regel in die output zowel het huidige als het piekgebruik voor de unit. Voer de worker uit met één pagina tegelijk, noteer de piek en herhaal dit met twee geopende pagina's om te zien wat een tweede pagina werkelijk kost. Concurrency is vervolgens een rekensom: neem het totale RAM-geheugen, trek daarvan af wat de rest van de machine nodig heeft, houd een paar honderd MB aan reserve over en deel dit door de gemeten piek per worker. Zie hoeveel RAM en CPU een agent-VPS nodig heeft voor het dimensioneren van de onderliggende machine.

Dwing dat getal op twee plaatsen af. Gebruik in uw code een vaste worker-pool of een semaphore, zodat een piek in agent-verzoeken in de wachtrij komt te staan in plaats van browsers te starten. Gebruik in het besturingssysteem een cgroup-limiet, zodat een bug in de wachtrij de machine niet platlegt:

[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=always

MemorySwapMax=0 is belangrijker dan het lijkt. Zonder deze instelling verplaatst de cgroup pagina's naar de swap wanneer de limiet wordt bereikt. Hierdoor blijft de machine draaien terwijl elk verzoek traag wordt, wat lastiger te diagnosticeren is dan een duidelijke fout. Met deze instelling beëindigt de kernel de browserboom binnen die cgroup, herstart systemd de unit en blijft sshd behouden. De equivalente instellingen in Compose zijn mem_limit, shm_size en init, zoals beschreven in geheugenlimieten instellen in Docker Compose.

Houd het browser-eindpunt buiten het publieke internet

Playwright kan de browser als server uitvoeren en uw agent voorzien van een WebSocket URL:

const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());

Dat eindpunt heeft geen inlogprocedure. De API-documentatie van Playwright stelt dit direct: "Elk proces of elke webpagina (inclusief die welke in Playwright draaien) met kennis van de wsPath kan de controle over de OS-gebruiker overnemen." De standaard host is localhost, die "alleen verbindingen accepteert vanaf de loopback-interface", en de documentatie waarschuwt dat het doorgeven van een expliciet adres zoals 0.0.0.0 "de browser RPC blootstelt aan alles wat de luisterende poort kan bereiken". Chrome's eigen --remote-debugging-port is nog risicovoller. Het DevTools-protocol bevat geen enkele vorm van authenticatie en is volledig afhankelijk van het binden aan loopback.

Controleer wat u daadwerkelijk heeft gepubliceerd en test dit zowel vanaf een tweede machine als vanaf de VPS:

ss -ltnp

Alles op een browserpoort dat gebonden is aan 0.0.0.0 is een beveiligingslek. Onthoud dat de meeste providers een afzonderlijke netwerkfirewall in hun configuratiescherm gebruiken, waar uw ufw-regels geen weet van hebben. Benader het eindpunt vanaf een andere machine via een SSH-tunnel of een privé-VPN:

ssh -N -L 3000:127.0.0.1:3000 you@your-vps

Het risico hier is groter dan alleen het stelen van browsertijd. Een browser die u kunt aansturen is een machine voor request-forgery die zich binnen uw netwerk bevindt. Iedereen die die socket bereikt, kan deze http://127.0.0.1:8080, uw database-beheerpagina of het cloud-metadata-adres op 169.254.169.254 laten ophalen en vervolgens het antwoord van de pagina lezen. Uw firewall ziet een verzoek dat afkomstig is van de VPS zelf, wat is toegestaan. Behandel het controle-eindpunt als gelijkwaardig aan shell-toegang op die server.

MCP-servers hebben dezelfde structuur. npx @playwright/mcp@latest --headless --port 8931 serveert via HTTP op localhost en --host 0.0.0.0 is de vlag die een lokale tool verandert in een publieke tool. De README van het project stelt duidelijk dat Playwright MCP "geen beveiligingsgrens" is. Houd de poort op loopback en laat de agent deze via dezelfde tunnel bereiken.

Pagina's die uw agent leest zijn niet-vertrouwde invoer

Een agent die het openbare internet doorzoekt, voert tekst geschreven door vreemden in een model dat ook uw instructies bevat. Een pagina kan tekst bevatten die aan dat model is gericht, met de opdracht om de taak te staken, een tool aan te roepen of gegevens naar een URL te posten. Het model ontvangt beide als tekst, waardoor het geen betrouwbare manier heeft om de woorden van een pagina te onderscheiden van die van u. Ontwerp de opzet zodanig dat een kwaadwillende pagina weinig mogelijkheden heeft.

  • Voer de browser uit onder een eigen OS-gebruiker, zonder SSH-sleutels en zonder cloud-inloggegevens in de omgeving.
  • Gebruik een nieuwe context per taak, en --isolated met Playwright MCP, zodat een sessie op de ene site niet beschikbaar is voor de volgende pagina.
  • Houd een toegestane lijst met domeinen (allowlist) bij wanneer de taak dit toelaat. Playwright MCP accepteert --allowed-origins en --blocked-origins als door puntkomma's gescheiden lijsten.
  • Vereis een menselijke tussenkomst voordat een actie wordt uitgevoerd die de status wijzigt, zoals het versturen van e-mail of het uitgeven van geld.

Beter nog, houd de volledige browser op een machine die u kunt verwijderen en opnieuw kunt opbouwen; dit is hetzelfde argument als het uitvoeren van code-agents in een wegwerpbare VM. Als de eigenlijke taak van de agent zoeken is in plaats van onbeperkt browsen, is een specifiekere tool veiliger dan een volledige browser: een zoekfunctie ondersteund door uw eigen SearXNG geeft resultaten terug zonder ooit de kwaadwillende pagina te laden.

FAQ

Waarom crasht Chromium in Docker, terwijl het op dezelfde VPS wel werkt?

Omdat de container standaard een /dev/shm van 64 MB krijgt, terwijl de host over een veel grotere beschikt. Chromium verwerkt gerenderde inhoud via dit gedeelde geheugengebied; een zware pagina vult dit volledig, waarna het renderproces stopt. Voer df -h /dev/shm uit in de container om dit te bevestigen en start de container vervolgens met --ipc=host, wat het gedeelde geheugen van de host gebruikt, of met --shm-size=1g, wat het eigen geheugen van de container vergroot. --disable-dev-shm-usage verplaatst het probleem enkel naar /tmp.

Is --no-sandbox veilig als de VPS niets anders draait?

Nee. De sandbox voorkomt dat een kwaadaardige pagina toegang krijgt tot de rest van de machine. De documentatie van Chromium stelt dat deze vlag "kritieke beveiligingsfuncties van Chromium uitschakelt en nooit gebruikt mag worden bij het browsen op het open web". Een agent die links volgt, browsed op het open web. Los de oorzaak op: draai de browser niet als root en voeg op Ubuntu 24.04 een AppArmor-profiel toe met userns, voor het pad naar het browser-binary, zodat niet-geprivilegieerde user namespaces voor dat specifieke programma zijn toegestaan.

Hoeveel browsers kan ik draaien op een kleine VPS?

Meet dit, neem geen getal over. Chromium start één renderer-proces per site, dus het antwoord hangt af van de pagina's die u opent. Draai één worker onder systemd-run met MemoryMax ingesteld, lees de piekwaarde af van de Memory:-regel in systemctl status, deel uw vrije RAM door die piek en houd reserve over. Handhaaf dit resultaat op twee manieren: met een wachtrij in uw code en een MemoryMax in het unit-bestand, zodat een piek in verzoeken wacht in plaats van dat de machine gaat swappen.

Kan mijn agent vanaf een andere machine verbinding maken met de browser?

Ja, maar bind de poort nooit aan 0.0.0.0. Het Playwright-server-eindpunt en de Chrome DevTools-poort accepteren elke client die ze kan bereiken, zonder wachtwoord. Laat de listener op 127.0.0.1 staan en leid de verbinding via een SSH-tunnel of een privé-VPN. Controleer dit met ss -ltnp op de server en een poortcontrole vanaf buitenaf; controleer ook de afzonderlijke netwerkfirewall van uw provider.

Waarom zijn mijn screenshots leeg terwijl de pagina duidelijk geladen is?

Ontbrekende lettertypes. Zonder lettertype dat het script van de pagina ondersteunt, wordt tekst weergegeven als lege vakjes of helemaal niet, waardoor een pagina met weinig afbeeldingen leeg lijkt. Voer fc-match "sans-serif:lang=ko" uit voor elke taal die u scrapt, installeer fonts-noto-core en fonts-noto-cjk wanneer het antwoord een algemene fallback is, en herstart de browser zodat fontconfig de cache ververst. Een container zonder lettertypes logt Fontconfig error: Cannot load default config file bij het opstarten.

#headless-browser#playwright#chromium#ai-agents#automation