SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor

How to Run Headless Chromium for AI Agents on VPS

Headless Chromium for AI agents fit fail from small /dev/shm, sandbox flags, missing fonts, or leaked processes. Set limits and protect the control endpoint.

Wetin you dey run

Headless browser for VPS na Chromium wey no get window, and na your code dey control am instead of person. For server, na long-running process tree wey your agent dey talk to through local socket. To install am, na one command. But na everything wey follow after installation dey require work. You go limit wetin browser fit use from the machine, and you go keep its control endpoint away from public internet.

This guide assume say you don already choose the tool and now you need operate am. If you still dey compare crawlers and extractors, start with self-hosted Firecrawl alternatives and come back. Everything below dey use Playwright's Chromium, because Playwright ships its own browser build and its own dependency installer. So, the same commands go work for bare Ubuntu VPS and inside container. Versions current as of August 2026.

Install Chromium without guessing at dependencies

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

--with-deps dey run apt for the shared libraries and fonts wey Chromium need, then e go ask for root when e reach there. The browser build itself go download enter ~/.cache/ms-playwright for the user wey run the command. This one matter for server, because service user no dey usually be the user wey you use log in. Install the system packages once as admin with sudo npx playwright install-deps chromium, then set PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers for both the install command and the service unit so one copy fit shared. Service wey no fit see its browser go fail when e start, with message wey name the path wey e search.

Pin the Playwright version. Each release dey tied to one browser build, so npm update wey no get pinned version fit change the browser under service wey dey run. Playwright 1.62 na the current version as of August 2026.

Two Chromium builds dey, and dem no be the same program. The default download na the headless shell, wey be smaller binary wey only dey run headless, and npx playwright install --with-deps --only-shell install only that one. The full browser na wetin you get with chromium channel, wey Playwright browser docs call "the real Chrome browser, and is thus more authentic, reliable, and offers more features". Use the shell for bulk fetching. Use the full browser when website dey behave differently and you need find out why.

Why headless browser dey crash inside container

Docker dey give each container 64 MB /dev/shm. Docker documentation talk am clearly: "If you omit the size entirely, the system uses 64m". Chromium dey pass rendered content between its processes through that shared memory area, so one heavy page fit fill am. Renderer go then die, and your client go report say target don crash, even though the page dey work fine for your laptop. Confirm the size from inside the container before you change anything.

df -h /dev/shm

Two real fixes dey, and dem na alternatives, no be pair wey you must use together. --ipc=host go put the container for host IPC namespace, so e go use the host's /dev/shm, wey normally na half of RAM. Playwright's Docker guide recommend am, because without am "Chromium can run out of memory and crash". The cost na say you lose IPC isolation between container and host. --shm-size=1g go keep the private namespace and simply make the mount bigger.

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

The flag --disable-dev-shm-usage na the answer wey you go find for most search results, but e dey do different thing: e move those files from /dev/shm go temporary directory. If /tmp dey on disk, you don exchange crash for slower rendering and disk writes. If /tmp na tmpfs, the data go return to RAM without any size limit, and this na one way browser fit chop small VPS. Size /dev/shm properly instead.

Wetin --no-sandbox really cost

Chromium dey isolate every renderer inside sandbox wey Linux user namespaces build. That sandbox na the boundary between hostile page and your server. When e no fit start, Chromium refuse to run, and log go get line like this:

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

The usual advice na --no-sandbox. Chromium own security documentation talk am clear: the flag "disables critical security features of Chromium and should never be used when browsing the open web". Agent wey dey follow links dey browse the open web by definition. Find the real cause.

Two causes cover almost every case. If browser dey run as root, e go disable the sandbox because e no fit drop privilege wey e already get. Na why Playwright image dey ship with ordinary user called pwuser. For Ubuntu 24.04 and later, AppArmor dey restrict unprivileged user namespaces, and e go deny Chromium binary for path wey no shipped profile cover. Playwright download under ~/.cache/ms-playwright na exactly this kind path. Check both:

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

A 1 from the sysctl plus kernel line wey contain apparmor="DENIED" operation="userns_create" confirm the second cause. Allow that one binary inside /etc/apparmor.d/pw-chromium. This one keep the restriction for everything else on the box:

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

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

Load am with sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. The path hold browser revision, so e go change every time you upgrade Playwright. The globs above go continue to work. Profile wey you write for one exact path fit silently stop to match, and browser go start to fail again after update wey look unrelated.

Why screenshots dey blank or full of boxes

Blank screenshot, or one wey full of empty rectangles, normally na font problem, no be rendering bug. install-deps dey pull working base: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic for Japanese, fonts-wqy-zenhei for Chinese, fonts-tlwg-loma-otf for Thai. Noto CJK no dey inside that set, so Korean and some other scripts go fall back to any font wey fontconfig fit find. Ask fontconfig instead of guessing:

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

If language wey you need resolve to unifont or fallback wey no get real glyphs, install fonts-noto-core and fonts-noto-cjk, then run the check again. Fontconfig dey cache its results, so restart the browser after you install fonts. Stripped image wey no get fonts at all dey log Fontconfig error: Cannot load default config file when e start, and e render every page empty.

Locale and time zone dey separate from fonts, and dem change wetin page talk, no be only how e look. Container normally get LANG unset and TZ for UTC, so sites go serve English and print UTC timestamps, while your agent go report times wey no match wetin person for that country dey see. Set dem per browser context instead of per machine, so one browser fit serve tasks for different regions.

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

Wetin make leaked browser processes push the box go swap

Two different problems dey share the name "zombie". True zombie na process wey don finish but e parent never call wait(). E hold PID entry and nothing else, so e no dey use memory. You go collect dem when browser dey run as PID 1 for container, because PID 1 no get default reaper. Docker --init flag fix exactly this one, by running small init wey "forwards signals and reaps processes". For Compose, na init: true be the same thing.

The leak wey really dey push your box go swap different: na live Chromium processes wey nobody close. E fit happen when task throw error between newContext() and close(), or when person kill the controlling script and e leave browser tree orphaned. The worst case na code wey dey launch fresh browser for every request. Count dem:

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

That count suppose return to e idle value between tasks. If e dey increase over one day, the fix dey inside your code, no be for launch flags: close the context inside finally block, close the browser for SIGTERM, and recycle the browser after fixed number of tasks instead of making one run for one month. Under systemd, stop or restart dey kill everything for the unit cgroup, so sudo systemctl restart browser.service na reliable reset. Browser wey person start by hand inside terminal multiplexer no get this guarantee, and e orphans go outlive the session.

Browser context wan need RAM

Ask the question well, because “one browser” no be one process. Chromium dey run browser process, GPU process, utility processes, and one renderer process for each site. Site isolation also give cross-site iframes their own renderer. A BrowserContext na separate cookie jar and storage area inside that same tree, so second context cheap. Second page no be cheap, because e dey start renderer processes, and page wey get plenty ads fit start several.

So wetin you need measure na peak memory for the whole tree under your own workload. Figure from another person blog no get value here, because the pages wey your agent open determine the answer. Measure am for the machine wey you go use, against the sites wey you go visit:

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

For Ubuntu 24.04, the Memory: line for that output dey report both current and peak use for the unit. Run the worker with one page at a time, record the peak, then repeat am with two pages open to see the real cost of second page. Concurrency na arithmetic: subtract wetin the rest of the box need from total RAM, keep few hundred MB as headroom, then divide by the measured peak per worker. To size the machine underneath this, see how much RAM and CPU agent VPS need.

Enforce that number for two places. For your code, use fixed worker pool or semaphore, so burst of agent requests go queue instead of launching browsers. For the OS, use cgroup limit, so bug for the queue no go carry the whole machine down with am:

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

MemorySwapMax=0 matter pass as e first look. Without am, the cgroup go push pages to swap when e reach the limit. The box go remain up, but every request go slow, and this one harder to diagnose than clean failure. With am, kernel go kill the browser tree inside that cgroup, systemd go restart the unit, and sshd go survive. The same controls for Compose na mem_limit, shm_size and init, wey setting memory limits for Docker Compose cover.

Keep the browser endpoint off the public internet

Playwright fit run the browser as a server and give your agent one WebSocket URL:

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

That endpoint no get login. Playwright API documentation talk am directly: "Any process or web page (including those running in Playwright) with knowledge of the wsPath can take control of the OS user." The default host na localhost, "accepting connections only from the loopback interface", and the docs warn say if you pass an explicit address like 0.0.0.0, e "exposes the browser RPC to anything that can reach the listening port". Chrome own --remote-debugging-port worse. The DevTools protocol no get any authentication and e depend completely on binding to loopback.

Check wetin you actually publish, and check am from another machine plus the VPS:

ss -ltnp

Anything wey dey on a browser port bound to 0.0.0.0 na a finding. Remember say most providers get separate network firewall for their control panel, and your ufw rules no know anything about am. Reach the endpoint from another machine through an SSH tunnel or private VPN instead:

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

The risk here pass person stealing browser time. Browser wey you fit control na request-forgery machine wey dey inside your network. Anybody wey reach that socket fit make am fetch http://127.0.0.1:8080, your database admin page, or the cloud metadata address for 169.254.169.254, then read the response from the page. Your firewall go see request wey come from the VPS itself, and e allowed. Treat the control endpoint like shell access on that box.

MCP servers get the same pattern. npx @playwright/mcp@latest --headless --port 8931 serve over HTTP on localhost, and --host 0.0.0.0 na the flag wey turn local tool into public one. The project README talk am plainly: Playwright MCP "is not a security boundary". Keep the port on loopback and make the agent reach am through the same tunnel.

Pages wey your agent dey read na untrusted input

Agent wey dey browse open web dey feed text wey strangers write into model wey still dey hold your instructions. Page fit carry text wey dem address to the model, tell am make e abandon the task, call tool, or post data go URL. The model receive both as text, so e no get reliable way to know whether na page words or your own instructions. Design the setup so hostile page get small thing to work with.

  • Run the browser under its own OS user, without SSH keys and cloud credentials for its environment.
  • Use fresh context for every task, and --isolated with Playwright MCP, so session for one site no dey available to the next page.
  • Keep origin allowlist where the job allow am. Playwright MCP dey take --allowed-origins and --blocked-origins as semicolon-separated lists.
  • Require human step before any action wey go change state, like sending mail or spending money.

Better still, keep the whole browser for machine wey you fit throw away and rebuild. Na the same reason as running coding agents for disposable VM. If the agent's real job na search instead of open-ended browsing, narrower tool dey safer than full browser: search skill wey your own SearXNG dey back dey return results without ever loading the hostile page.

FAQ

Why Chromium dey crash for Docker but e dey work well directly for the same VPS?

Because container get 64 MB /dev/shm by default, while host own much bigger. Chromium dey pass rendered content through that shared memory area, so heavy page fit fill am and make renderer die. Run df -h /dev/shm inside container to confirm, then start am either with --ipc=host, wey dey use host shared memory, or with --shm-size=1g, wey dey increase container own. --disable-dev-shm-usage only move the problem go /tmp.

--no-sandbox safe if VPS no dey run anything else?

No. Sandbox na wetin stop malicious page from reaching the rest of the machine, and Chromium documentation talk say the flag "disables critical security features of Chromium and should never be used when browsing the open web". Agent wey dey follow links dey browse the open web. Fix the cause instead: no run browser as root, and for Ubuntu 24.04 add AppArmor profile wey carry userns, for browser binary path, so unprivileged user namespaces fit work for that one program.

How many browsers I fit run for small VPS?

Measure am; no just copy number. Chromium dey start one renderer process for each site, so the answer depend on the pages wey you open. Run one worker under systemd-run with MemoryMax set, read the peak from Memory: line inside systemctl status, then divide your free RAM by that peak and keep headroom. Enforce the result two ways: use queue for your code and MemoryMax inside unit file, so burst of requests go wait instead of making machine swap.

My agent fit connect to browser from another machine?

Yes, but never bind the port to 0.0.0.0. Playwright server endpoint and Chrome DevTools port go accept any client wey fit reach dem, with no password. Keep listener for 127.0.0.1 and carry the connection through SSH tunnel or private VPN. Verify with ss -ltnp for server and check the port from outside. Also check your provider separate network firewall.

Why my screenshots blank when page clearly load?

Fonts dey miss. If no font cover the page script, text go render as empty boxes or no render at all, so page wey get few images go come back like say e blank. Run fc-match "sans-serif:lang=ko" for each language wey you scrape. Install fonts-noto-core and fonts-noto-cjk when the answer na generic fallback, then restart browser so fontconfig fit reload its cache. Container wey no get fonts at all go log Fontconfig error: Cannot load default config file for startup.

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