SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Run a headless browser for AI agents on a VPS

Headless Chromium on a VPS breaks in known ways: tiny /dev/shm, sandbox flags, missing fonts, leaked processes. Set the limits before your agent hits them.

What you are running

A headless browser on a VPS is Chromium with no window, driven by your code instead of a person. On a server it is a long-lived process tree that your agent talks to over a local socket. Installing it takes one command. The work is everything after that. You cap what the browser may take from the machine, and you keep its control endpoint off the public internet.

This guide assumes the tool choice is already made and you now have to operate it. If you are still comparing crawlers and extractors, start with the self-hosted Firecrawl alternatives and come back. Everything below uses Playwright's Chromium, because Playwright ships its own browser build and its own dependency installer, so the same commands work on a bare Ubuntu VPS and inside a container. Versions are 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 runs apt for the shared libraries and fonts Chromium needs, and asks for root when it gets there. The browser build itself downloads into ~/.cache/ms-playwright for the user who ran the command. That matters on a server, because the service user is usually not the user you log in as. Install the system packages once as an admin with sudo npx playwright install-deps chromium, then set PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers in both the install command and the service unit so one copy is shared. A service that cannot see its browser fails at launch with a message naming the path it searched.

Pin the Playwright version. Each release is tied to one browser build, so an unpinned npm update can swap the browser under a running service. Playwright 1.62 is current as of August 2026.

Two Chromium builds exist and they are not the same program. The default download is the headless shell, a smaller binary that only runs headless, and npx playwright install --with-deps --only-shell installs that alone. The full browser is what you get with the chromium channel, which Playwright's 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 a site behaves differently and you need to find out why.

Why a headless browser crashes in a container

Docker gives each container a 64 MB /dev/shm. The Docker documentation is explicit: "If you omit the size entirely, the system uses 64m". Chromium passes rendered content between its processes through that shared memory area, so one heavy page can fill it. The renderer then dies and your client reports a crashed target, on a page that works fine on your laptop. Confirm the size from inside the container before you change anything.

df -h /dev/shm

There are two real fixes and they are alternatives, not a pair. --ipc=host puts the container in the host IPC namespace so it uses the host's /dev/shm, which is normally half of RAM. Playwright's Docker guide recommends it, because without it "Chromium can run out of memory and crash". The cost is that you drop IPC isolation between container and host. --shm-size=1g keeps the private namespace and simply makes 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 is the answer you will find in most search results, and it does something different: it moves those files out of /dev/shm into a temporary directory. If /tmp sits on disk, you traded a crash for slower rendering and disk writes. If /tmp is a tmpfs, the data is back in RAM with no size limit at all, which is one way a browser eats a small VPS. Size /dev/shm properly instead.

What --no-sandbox actually costs

Chromium isolates every renderer in a sandbox built on Linux user namespaces. That sandbox is the boundary between a hostile page and your server. When it cannot start, Chromium refuses to run, and the log holds a line like this:

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

The usual advice is --no-sandbox. Chromium's own security documentation is blunt about the price: the flag "disables critical security features of Chromium and should never be used when browsing the open web". An agent following links is browsing the open web by definition. Find the real cause.

Two causes cover almost every case. Running the browser as root disables the sandbox, because it cannot drop privileges it already holds, which is why Playwright's image ships an ordinary user called pwuser. On Ubuntu 24.04 and later, AppArmor restricts unprivileged user namespaces, and a Chromium binary at a path no shipped profile covers gets denied. Playwright's download under ~/.cache/ms-playwright is exactly such a path. Check both:

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

A 1 from the sysctl plus a kernel line containing apparmor="DENIED" operation="userns_create" confirms the second cause. Allow that one binary in /etc/apparmor.d/pw-chromium, which keeps the restriction in place 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 it with sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. The path holds the browser revision, so it changes on every Playwright upgrade. The globs above survive that. A profile written against one exact path silently stops matching, and the browser starts failing again after an update that looked unrelated.

Why screenshots come back blank or full of boxes

A blank screenshot, or one full of empty rectangles, is usually a font problem rather than a rendering bug. install-deps pulls a 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. There is no Noto CJK in that set, so Korean and several other scripts fall back to whatever fontconfig can find. Ask fontconfig instead of guessing:

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

If a language you care about resolves to unifont or to a fallback with no real glyphs, install fonts-noto-core and fonts-noto-cjk, then run the check again. Fontconfig caches its results, so restart the browser after installing fonts. A stripped image with no fonts at all logs Fontconfig error: Cannot load default config file at startup and renders every page empty.

Locale and time zone are separate from fonts, and they change what the page says, not only how it looks. A container normally has LANG unset and TZ at UTC, so sites serve English and print UTC timestamps, and your agent reports times that do not match what a person in that country sees. Set them per browser context rather than per machine, so one browser can serve tasks for different regions.

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

Why leaked browser processes make the box swap

Two different problems share the name "zombie". A true zombie is a finished process whose parent never called wait(). It holds a PID entry and nothing else, so it does not consume memory. You collect these when the browser runs as PID 1 in a container, because PID 1 has no default reaper. Docker's --init flag fixes exactly that, by running a small init that "forwards signals and reaps processes". In Compose the same thing is init: true.

The leak that actually swaps your box is different: live Chromium processes that nobody closed. It happens when a task throws between newContext() and close(), or when the controlling script is killed and leaves its browser tree orphaned. The worst version is code that launches a fresh browser for every request. Count them:

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

That count should return to its idle value between tasks. If it climbs over a day, the fix is in your code and not in the launch flags: close the context in a finally block, close the browser on SIGTERM, and recycle the browser after a fixed number of tasks instead of running one for a month. Under systemd, a stop or restart kills everything in the unit's cgroup, so sudo systemctl restart browser.service is a reliable reset. A browser started by hand inside a terminal multiplexer has no such guarantee, and its orphans outlive the session.

How much RAM does one browser context need

Ask the question precisely, because "one browser" is not one process. Chromium runs a browser process, a GPU process, utility processes, and one renderer process per site, and site isolation gives cross-site iframes their own renderer as well. A BrowserContext is a separate cookie jar and storage area inside that same tree, so a second context is cheap. A second page is not, because it starts renderer processes, and an ad-heavy page starts several.

So the number to measure is peak memory for the whole tree under your own workload. A figure from someone else's blog is worthless here, because the pages your agent opens decide the answer. Measure on the machine you will use, against the sites you will 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

On Ubuntu 24.04 the Memory: line in that output reports both current and peak use for the unit. Run the worker with one page at a time, note the peak, then repeat with two pages open to see what a second page really costs. Concurrency is then arithmetic: take total RAM, subtract what the rest of the box needs, keep a few hundred MB of headroom, and divide by the measured peak per worker. For sizing the machine underneath this, see how much RAM and CPU an agent VPS needs.

Enforce that number in two places. In your code, use a fixed worker pool or a semaphore, so a burst of agent requests queues instead of launching browsers. In the OS, use a cgroup limit, so a bug in the queue cannot take the machine down with it:

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

MemorySwapMax=0 matters more than it looks. Without it, the cgroup pushes pages to swap when it reaches the limit, so the box stays up while every request gets slow, which is harder to diagnose than a clean failure. With it, the kernel kills the browser tree inside that cgroup, systemd restarts the unit, and sshd survives. The same controls in Compose are mem_limit, shm_size and init, covered in setting memory limits in Docker Compose.

Keep the browser endpoint off the public internet

Playwright can run the browser as a server and hand your agent a WebSocket URL:

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

That endpoint has no login. Playwright's API documentation states it 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 is localhost, "accepting connections only from the loopback interface", and the docs warn that passing an explicit address such as 0.0.0.0 "exposes the browser RPC to anything that can reach the listening port". Chrome's own --remote-debugging-port is worse. The DevTools protocol has no authentication of any kind and depends entirely on being bound to loopback.

Check what you have actually published, and check it from a second machine as well as from the VPS:

ss -ltnp

Anything on a browser port bound to 0.0.0.0 is a finding. Remember that most providers run a separate network firewall in their control panel, which your ufw rules know nothing about. Reach the endpoint from another machine through an SSH tunnel or a private VPN instead:

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

The risk here is larger than someone stealing browser time. A browser you can drive is a request-forgery machine sitting inside your network. Whoever reaches that socket can make it fetch http://127.0.0.1:8080, your database admin page, or the cloud metadata address at 169.254.169.254, and then read the response out of the page. Your firewall sees a request coming from the VPS itself, which is allowed. Treat the control endpoint as equal to shell access on that box.

MCP servers have the same shape. npx @playwright/mcp@latest --headless --port 8931 serves over HTTP on localhost, and --host 0.0.0.0 is the flag that turns a local tool into a public one. The project's README says plainly that Playwright MCP "is not a security boundary". Keep the port on loopback and let the agent reach it over the same tunnel.

The pages your agent reads are untrusted input

An agent that browses the open web feeds text written by strangers into a model that also holds your instructions. A page can carry text addressed to that model, telling it to abandon the task, call a tool, or post data to a URL. The model receives both as text, so it has no reliable way to tell a page's words from yours. Design the setup so a hostile page has little to work with.

  • Run the browser under its own OS user, with no SSH keys and no cloud credentials in its environment.
  • Use a fresh context per task, and --isolated with Playwright MCP, so a session on one site is not available to the next page.
  • Keep an origin allowlist where the job allows one. Playwright MCP takes --allowed-origins and --blocked-origins as semicolon-separated lists.
  • Require a human step before any action that changes state, such as sending mail or spending money.

Better still, keep the whole browser on a machine you can throw away and rebuild, which is the same argument as running coding agents in a disposable VM. If the agent's real job is search rather than open-ended browsing, a narrower tool is safer than a full browser: a search skill backed by your own SearXNG returns results without ever loading the hostile page.

FAQ

Why does Chromium crash in Docker but work fine on the same VPS directly?

Because the container gets a 64 MB /dev/shm by default while the host has a much larger one. Chromium passes rendered content through that shared memory area, so a heavy page fills it and the renderer dies. Run df -h /dev/shm inside the container to confirm, then start it either with --ipc=host, which uses the host's shared memory, or with --shm-size=1g, which enlarges the container's own. --disable-dev-shm-usage only relocates the problem to /tmp.

Is --no-sandbox safe if the VPS runs nothing else?

No. The sandbox is what stops a malicious page from reaching the rest of the machine, and Chromium's documentation says the flag "disables critical security features of Chromium and should never be used when browsing the open web". An agent following links is browsing the open web. Fix the cause instead: do not run the browser as root, and on Ubuntu 24.04 add an AppArmor profile carrying userns, for the browser binary path, so unprivileged user namespaces are allowed for that one program.

How many browsers can I run on a small VPS?

Measure it, do not copy a number. Chromium starts one renderer process per site, so the answer depends on the pages you open. Run one worker under systemd-run with MemoryMax set, read the peak from the Memory: line in systemctl status, then divide your free RAM by that peak and keep headroom. Enforce the result twice, with a queue in your code and a MemoryMax in the unit file, so a burst of requests waits instead of swapping the machine.

Can my agent connect to the browser from another machine?

Yes, but never by binding the port to 0.0.0.0. The Playwright server endpoint and the Chrome DevTools port both accept any client that can reach them, with no password. Keep the listener on 127.0.0.1 and carry the connection over an SSH tunnel or a private VPN. Verify with ss -ltnp on the server and a port check from outside, and look at your provider's separate network firewall too.

Why are my screenshots blank when the page clearly loaded?

Missing fonts. With no font covering the page's script, text renders as empty boxes or not at all, so an image-light page comes back looking blank. Run fc-match "sans-serif:lang=ko" for each language you scrape, install fonts-noto-core and fonts-noto-cjk when the answer is a generic fallback, and restart the browser so fontconfig reloads its cache. A container with no fonts at all logs Fontconfig error: Cannot load default config file at startup.

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