SSD Nodes Learn 🎉 VPS dès $5.50/mois
Guides Matt ConnorPar Matt Connor

Faire tourner Chromium headless pour un agent IA sur VPS

Évitez les erreurs Chromium liées à /dev/shm, au sandbox et aux polices sur VPS. Ce guide couvre aussi les processus orphelins et l’accès local sécurisé.

Ce que vous exécutez

Un navigateur headless sur un VPS est une instance de Chromium sans interface graphique, pilotée par votre code et non par une personne. Sur un serveur, il s’agit d’un arbre de processus qui reste actif et auquel votre agent accède via un socket local. L’installation tient en une commande. Le travail commence ensuite. Vous limitez les ressources que le navigateur peut utiliser sur la machine et vous gardez son endpoint de contrôle hors de l’Internet public.

Ce guide part du principe que vous avez déjà choisi l’outil et que vous devez maintenant l’exploiter. Si vous comparez encore les crawlers et les extracteurs, commencez par les alternatives auto-hébergées à Firecrawl, puis revenez ici. Tout ce qui suit utilise le Chromium de Playwright, car Playwright fournit son propre build du navigateur et son propre installateur de dépendances. Les mêmes commandes fonctionnent donc sur un VPS Ubuntu nu comme dans un conteneur. Les versions sont à jour en août 2026.

Installer Chromium sans deviner ses dépendances

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

--with-deps exécute apt pour installer les bibliothèques partagées et les polices dont Chromium a besoin, puis demande les privilèges root lorsque c’est nécessaire. Le build du navigateur est téléchargé dans ~/.cache/ms-playwright pour l’utilisateur qui a exécuté la commande. Cela a son importance sur un serveur, car l’utilisateur du service n’est généralement pas celui avec lequel vous vous connectez. Installez une seule fois les paquets système en tant qu’administrateur avec sudo npx playwright install-deps chromium, puis définissez PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers à la fois dans la commande d’installation et dans l’unité de service afin de partager une seule copie. Un service qui ne peut pas accéder à son navigateur échoue au démarrage avec un message indiquant le chemin qu’il a recherché.

Épinglez la version de Playwright. Chaque version est associée à un build de navigateur précis. Un npm update sans version peut donc remplacer le navigateur utilisé par un service en cours d’exécution. Playwright 1.62 est la version actuelle en août 2026.

Deux builds de Chromium existent, et ce ne sont pas le même programme. Le téléchargement par défaut correspond au headless shell, un binaire plus petit qui fonctionne uniquement en mode headless, et npx playwright install --with-deps --only-shell installe uniquement celui-ci. Le navigateur complet est obtenu avec le channel chromium, que la documentation des navigateurs de Playwright décrit comme « le vrai navigateur Chrome, donc plus authentique, plus fiable et doté de davantage de fonctionnalités ». Utilisez le shell pour les téléchargements en masse. Utilisez le navigateur complet lorsqu’un site se comporte différemment et que vous devez en déterminer la cause.

Pourquoi un navigateur headless plante dans un conteneur

Docker attribue un /dev/shm de 64 MB à chaque conteneur. La documentation Docker est explicite : « Si vous omettez complètement la taille, le système utilise 64m ». Chromium fait transiter le contenu rendu entre ses processus par cette zone de mémoire partagée. Une page lourde peut donc la remplir. Le renderer s’arrête alors et votre client signale que la cible a planté, alors que la page fonctionne correctement sur votre ordinateur portable. Vérifiez la taille depuis l’intérieur du conteneur avant de modifier quoi que ce soit.

df -h /dev/shm

Il existe deux véritables solutions. Elles s’excluent mutuellement et ne se combinent pas. --ipc=host place le conteneur dans l’espace de noms IPC de l’hôte. Il utilise ainsi le /dev/shm de l’hôte, qui représente normalement la moitié de la RAM. Le guide Docker de Playwright le recommande, car sans cette option « Chromium peut manquer de mémoire et planter ». En contrepartie, vous perdez l’isolation IPC entre le conteneur et l’hôte. --shm-size=1g conserve l’espace de noms privé et augmente simplement la taille du montage.

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

L’option --disable-dev-shm-usage est la réponse proposée dans la plupart des résultats de recherche, mais elle fait autre chose : elle déplace ces fichiers de /dev/shm vers un répertoire temporaire. Si /tmp se trouve sur disque, vous remplacez le plantage par un rendu plus lent et des écritures disque. Si /tmp est un tmpfs, les données retournent en RAM sans aucune limite de taille. C’est une façon pour un navigateur de consommer toute la mémoire d’un petit VPS. Dimensionnez correctement /dev/shm à la place.

Ce que coûte réellement --no-sandbox

Chromium isole chaque renderer dans une sandbox reposant sur les user namespaces de Linux. Cette sandbox constitue la frontière entre une page hostile et votre serveur. Lorsqu’elle ne peut pas démarrer, Chromium refuse de s’exécuter et le journal contient une ligne similaire à celle-ci :

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

Le conseil habituel est --no-sandbox. La documentation de sécurité de Chromium est claire sur le sujet : ce flag « désactive des fonctions de sécurité critiques de Chromium et ne doit jamais être utilisé pour naviguer sur le Web ouvert ». Un agent qui suit des liens navigue, par définition, sur le Web ouvert. Recherchez la véritable cause.

Deux causes couvrent presque tous les cas. L’exécution du navigateur avec root désactive la sandbox, car Chromium ne peut pas abandonner des privilèges qu’il possède déjà. C’est pourquoi l’image Playwright fournit un utilisateur ordinaire nommé pwuser. À partir d’Ubuntu 24.04, AppArmor restreint les user namespaces non privilégiés. Un binaire Chromium situé à un chemin couvert par aucun profil fourni est alors refusé. Le téléchargement de Playwright sous ~/.cache/ms-playwright se trouve précisément dans ce cas. Vérifiez les deux éléments :

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

Un 1 provenant du sysctl, associé à une ligne du kernel contenant apparmor="DENIED" operation="userns_create", confirme la seconde cause. Autorisez ce binaire précis dans /etc/apparmor.d/pw-chromium. La restriction reste ainsi appliquée à tout le reste du système :

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

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

Chargez-le avec sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Le chemin contient la révision du navigateur et change donc à chaque mise à niveau de Playwright. Les globs ci-dessus continuent de fonctionner. Un profil défini pour un chemin exact cesse silencieusement de correspondre, et le navigateur recommence à échouer après une mise à jour qui semblait sans rapport.

Pourquoi les captures d’écran sont vierges ou remplies de carrés

Une capture d’écran vierge ou remplie de rectangles vides indique généralement un problème de polices, et non un bug de rendu. install-deps fournit une base fonctionnelle : fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic pour le japonais, fonts-wqy-zenhei pour le chinois et fonts-tlwg-loma-otf pour le thaï. Cet ensemble ne contient aucune police Noto CJK. Le coréen et plusieurs autres scripts utilisent donc la police de remplacement que fontconfig peut trouver. Interrogez fontconfig au lieu de deviner :

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

Si la langue qui vous intéresse utilise unifont ou une police de remplacement sans glyphes réels, installez fonts-noto-core et fonts-noto-cjk, puis relancez la vérification. Fontconfig met ses résultats en cache. Redémarrez donc le navigateur après l’installation des polices. Une image allégée qui ne contient aucune police journalise Fontconfig error: Cannot load default config file au démarrage et affiche toutes les pages vides.

La locale et le fuseau horaire sont distincts des polices. Ils modifient le contenu de la page, et pas seulement son apparence. Un conteneur a normalement LANG non défini et TZ réglé sur UTC. Les sites servent donc du contenu en anglais et affichent les horodatages en UTC. Votre agent signale alors des heures qui ne correspondent pas à celles visibles par une personne dans ce pays. Définissez ces paramètres par contexte de navigateur plutôt qu’au niveau de la machine. Un même navigateur peut ainsi exécuter des tâches pour différentes régions.

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

Pourquoi des processus de navigateur oubliés font utiliser le swap à la machine

Deux problèmes différents sont désignés par le terme « zombie ». Un véritable processus zombie est un processus terminé dont le parent n’a jamais appelé wait(). Il conserve une entrée de PID, mais rien d’autre. Il ne consomme donc pas de mémoire. Vous en accumulez lorsque le navigateur s’exécute avec le PID 1 dans un conteneur, car le PID 1 ne dispose d’aucun mécanisme de réapage par défaut. L’option --init de Docker corrige précisément ce problème en exécutant un petit processus init qui « transmet les signaux et réapage les processus ». Dans Compose, l’équivalent est init: true.

La fuite qui fait réellement utiliser le swap à la machine est différente : il s’agit de processus Chromium encore actifs que personne n’a fermés. Elle se produit lorsqu’une tâche lève une exception entre newContext() et close(), ou lorsque le script de contrôle est tué et laisse son arbre de processus du navigateur orphelin. Le pire cas est celui d’un code qui lance un nouveau navigateur pour chaque requête. Comptez-les :

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

Ce nombre doit revenir à sa valeur au repos entre les tâches. S’il augmente au fil de la journée, le correctif se trouve dans votre code, pas dans les options de lancement : fermez le contexte dans un bloc finally, fermez le navigateur à SIGTERM et recyclez le navigateur après un nombre fixe de tâches au lieu de le laisser fonctionner pendant un mois. Avec systemd, un arrêt ou un redémarrage tue tous les processus du cgroup de l’unité. sudo systemctl restart browser.service constitue donc une réinitialisation fiable. Un navigateur lancé manuellement dans un terminal multiplexé ne bénéficie d’aucune garantie équivalente, et ses processus orphelins survivent à la session.

Combien de RAM faut-il à un contexte de navigateur

Posez la question avec précision, car « un navigateur » ne correspond pas à un seul processus. Chromium exécute un processus de navigateur, un processus GPU, des processus utilitaires et un processus renderer par site. L’isolation des sites attribue également leur propre renderer aux iframes provenant d’autres sites. Un BrowserContext est un conteneur de cookies et une zone de stockage distincts dans cette même arborescence. Un deuxième contexte coûte donc peu de mémoire. Ce n’est pas le cas d’une deuxième page, car elle lance des processus renderer. Une page contenant beaucoup de publicités en lance plusieurs.

La valeur à mesurer est donc la consommation mémoire maximale de toute l’arborescence, avec votre propre charge de travail. Une valeur publiée sur le blog d’un tiers ne vous sera d’aucune utilité, car la réponse dépend des pages ouvertes par votre agent. Effectuez la mesure sur la machine que vous utiliserez, avec les sites que vous consulterez :

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

Sur Ubuntu 24.04, la ligne Memory: de cette sortie indique la consommation actuelle et maximale de l’unité. Exécutez le worker avec une seule page à la fois et notez le pic. Recommencez ensuite avec deux pages ouvertes pour mesurer le coût réel d’une deuxième page. Le calcul de la concurrence est alors simple : prenez la quantité totale de RAM, soustrayez la mémoire nécessaire au reste de la machine, conservez quelques centaines de MB de marge, puis divisez le résultat par le pic mesuré pour chaque worker. Pour dimensionner la machine sous-jacente, consultez la quantité de RAM et de CPU nécessaire à un VPS d’agent.

Imposez cette limite à deux niveaux. Dans votre code, utilisez un pool de workers fixe ou un sémaphore. Ainsi, un afflux de requêtes d’agent est mis en file d’attente au lieu de lancer plusieurs navigateurs. Dans le système d’exploitation, utilisez une limite cgroup. Un bug dans la file d’attente ne pourra ainsi pas entraîner toute la machine avec lui :

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

MemorySwapMax=0 est plus important qu’il n’y paraît. Sans ce paramètre, le cgroup envoie des pages dans le swap lorsqu’il atteint la limite. La machine reste alors disponible, mais toutes les requêtes ralentissent. Ce problème est plus difficile à diagnostiquer qu’un échec net. Avec ce paramètre, le kernel tue l’arborescence du navigateur dans ce cgroup, systemd redémarre l’unité et sshd est conservé. Dans Compose, les contrôles équivalents sont mem_limit, shm_size et init. Ils sont présentés dans la configuration des limites de mémoire dans Docker Compose.

N’exposez pas l’endpoint du navigateur sur Internet

Playwright peut exécuter le navigateur en tant que serveur et fournir à votre agent une URL WebSocket :

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

Cet endpoint ne comporte aucune authentification. La documentation de l’API Playwright l’indique clairement : « Tout processus ou toute page web, y compris ceux exécutés dans Playwright, qui connaît le wsPath peut prendre le contrôle de l’utilisateur du système d’exploitation. » L’hôte par défaut est localhost, « qui n’accepte les connexions que depuis l’interface loopback », et la documentation avertit que la transmission d’une adresse explicite telle que 0.0.0.0 « expose le RPC du navigateur à tout ce qui peut atteindre le port en écoute ». Le --remote-debugging-port de Chrome est encore plus risqué. Le protocole DevTools ne fournit aucune authentification et dépend entièrement d’une liaison sur loopback.

Vérifiez ce que vous avez réellement publié. Effectuez également ce contrôle depuis une deuxième machine, et pas seulement depuis le VPS :

ss -ltnp

Tout port de navigateur lié à 0.0.0.0 constitue un problème de sécurité. N’oubliez pas que la plupart des fournisseurs exécutent un firewall réseau distinct dans leur panneau de contrôle. Vos règles ufw n’en ont aucune connaissance. Accédez à l’endpoint depuis une autre machine au moyen d’un tunnel SSH ou d’un VPN privé :

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

Le risque ne se limite pas au vol de temps de navigation. Un navigateur que vous pouvez piloter est un outil de falsification de requêtes situé à l’intérieur de votre réseau. Toute personne qui atteint ce socket peut lui faire interroger http://127.0.0.1:8080, votre page d’administration de base de données ou l’adresse de métadonnées cloud 169.254.169.254, puis lire la réponse dans la page. Votre firewall voit une requête provenant du VPS lui-même, ce qui est autorisé. Considérez l’endpoint de contrôle comme équivalent à un accès shell sur cette machine.

Les serveurs MCP présentent le même risque. npx @playwright/mcp@latest --headless --port 8931 sert du contenu HTTP sur localhost, et --host 0.0.0.0 est l’option qui transforme un outil local en service public. Le README du projet indique clairement que Playwright MCP « n’est pas une frontière de sécurité ». Laissez le port sur loopback et permettez à l’agent d’y accéder via le même tunnel.

Les pages que votre agent consulte sont des entrées non fiables

Un agent qui parcourt le Web ouvert transmet à un modèle du texte écrit par des inconnus, alors que ce modèle contient aussi vos instructions. Une page peut contenir du texte destiné au modèle et lui demander d’abandonner la tâche, d’appeler un outil ou d’envoyer des données vers une URL. Le modèle reçoit ces deux contenus sous forme de texte. Il ne peut donc pas distinguer de manière fiable les mots d’une page des vôtres. Configurez le système de façon à limiter ce qu’une page hostile peut exploiter.

  • Exécutez le navigateur avec son propre utilisateur système, sans clés SSH ni identifiants cloud dans son environnement.
  • Utilisez un contexte vierge pour chaque tâche et --isolated avec Playwright MCP, afin qu’une session ouverte sur un site ne soit pas accessible à la page suivante.
  • Lorsque le travail le permet, définissez une liste d’autorisation des origines. Playwright MCP accepte --allowed-origins et --blocked-origins sous forme de listes séparées par des points-virgules.
  • Exigez une intervention humaine avant toute action qui modifie l’état, comme l’envoi d’un e-mail ou une dépense.

Mieux encore, exécutez l’ensemble du navigateur sur une machine que vous pouvez supprimer et reconstruire. Le principe est le même que pour exécuter des agents de codage dans une VM temporaire. Si le véritable objectif de l’agent est la recherche plutôt que la navigation ouverte, un outil plus limité est plus sûr qu’un navigateur complet : une skill de recherche reposant sur votre propre SearXNG renvoie les résultats sans jamais charger la page hostile.

FAQ

Pourquoi Chromium plante-t-il dans Docker alors qu’il fonctionne correctement sur le même VPS directement ?

Parce que le conteneur reçoit par défaut un /dev/shm de 64 MB, tandis que l’hôte en possède un beaucoup plus grand. Chromium fait transiter le contenu rendu par cette zone de mémoire partagée. Une page lourde la remplit donc et le processus de rendu s’arrête. Exécutez df -h /dev/shm dans le conteneur pour le confirmer, puis démarrez-le avec --ipc=host, qui utilise la mémoire partagée de l’hôte, ou avec --shm-size=1g, qui augmente celle du conteneur. --disable-dev-shm-usage ne fait que déplacer le problème vers /tmp.

L’option --no-sandbox est-elle sûre si le VPS n’exécute rien d’autre ?

Non. Le sandbox empêche une page malveillante d’accéder au reste de la machine. La documentation de Chromium précise que cette option « désactive des fonctions de sécurité essentielles de Chromium et ne doit jamais être utilisée pour naviguer sur le Web ouvert ». Un agent qui suit des liens navigue sur le Web ouvert. Corrigez plutôt la cause : n’exécutez pas le navigateur en tant que root et, sur Ubuntu 24.04, ajoutez un profil AppArmor contenant userns, pour le chemin du binaire du navigateur afin d’autoriser les user namespaces non privilégiés pour ce seul programme.

Combien de navigateurs puis-je exécuter sur un petit VPS ?

Mesurez cette capacité au lieu de reprendre un chiffre. Chromium démarre un processus de rendu par site. La réponse dépend donc des pages que vous ouvrez. Exécutez un worker avec systemd-run et définissez MemoryMax, relevez le pic sur la ligne Memory: de systemctl status, puis divisez votre RAM disponible par ce pic en conservant une marge. Appliquez cette limite à deux niveaux : avec une file d’attente dans votre code et un MemoryMax dans le fichier d’unité, afin qu’un afflux de requêtes attende au lieu de provoquer du swap sur la machine.

Mon agent peut-il se connecter au navigateur depuis une autre machine ?

Oui, mais ne liez jamais le port à 0.0.0.0. Le endpoint du serveur Playwright et le port Chrome DevTools acceptent tout client capable de les atteindre, sans mot de passe. Laissez le listener sur 127.0.0.1 et faites passer la connexion par un tunnel SSH ou un VPN privé. Vérifiez avec ss -ltnp sur le serveur et effectuez un contrôle du port depuis l’extérieur. Vérifiez également le firewall réseau distinct de votre fournisseur.

Pourquoi mes captures d’écran sont-elles vides alors que la page est manifestement chargée ?

Les polices sont absentes. Si aucune police ne couvre l’écriture utilisée par la page, le texte s’affiche sous forme de cases vides ou ne s’affiche pas du tout. Une page contenant peu d’images semble alors vide. Exécutez fc-match "sans-serif:lang=ko" pour chaque langue que vous scrapez, installez fonts-noto-core et fonts-noto-cjk lorsque la réponse est une police de remplacement générique, puis redémarrez le navigateur afin que fontconfig recharge son cache. Un conteneur sans aucune police journalise Fontconfig error: Cannot load default config file au démarrage.

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