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

Self-host Moli, a headless browser for agents

Headless Chrome is the biggest process on a small VPS. Install Moli, serve CDP on loopback, point an agent at it, and learn where it stops working.

A headless browser that fits on a small VPS

Moli is a headless browser for AI agents, and it is small enough to self-host on a VPS where headless Chrome will not fit. It is a browser engine written in Rust, not a wrapper around Chromium, and it answers the Chrome DevTools Protocol (CDP), the protocol your automation library already speaks. You install one binary, run moli serve, then point Playwright or your own agent code at http://127.0.0.1:9222.

Read the trade before you install anything. The project states its own scope plainly: no GUI browser, no GPU compositor, no pixel-for-pixel parity with Chrome, and no high fidelity Canvas or media playback. Pages that need those will fail. Real Chrome under Playwright stays the fallback, and the last section shows you how to decide which pages need it.

Every command below comes from the project's README and its published skill files, checked in August 2026. Every number in the charts is a figure the project published about its own engine, not a measurement from this site, and each chart caption says so. If you are still choosing an engine, the wider survey of headless browsers for agents on a VPS covers the alternatives.

Why does headless Chrome use so much memory?

Chrome is a multi-process browser. Each tab and each cross-site iframe gets its own renderer process, and every renderer carries its own V8 heap and its own graphics buffers. That design is right for a desktop, where one crashed tab must not take the whole window down with it. On a 2 GB VPS it means a single browse step can cost more memory than the application you are actually running.

The project crawled 192 mixed public URLs with four engines and published the result.

ChartMixed public web crawl, 192 URLs, figures published by the Moli project
The data behind this chart
[
  {
    "engine": "Moli",
    "useful_pages": 103,
    "median_rss_mib": 73
  },
  {
    "engine": "Chrome Headless",
    "useful_pages": 101,
    "median_rss_mib": 773
  },
  {
    "engine": "Lightpanda",
    "useful_pages": 85,
    "median_rss_mib": 40
  },
  {
    "engine": "Obscura",
    "useful_pages": 57,
    "median_rss_mib": 39
  }
]

Chrome Headless returned 101 useful pages and Moli returned 103, so on that sample the two engines read roughly the same share of the web. Memory is where they separate: a median RSS (resident set size, the memory a process actually holds in RAM) of 773 MiB for Chrome against 73 MiB for Moli. The direction is credible because it follows from the process architecture. The exact ratio on your pages is not something you should assume.

Median is not the number that hurts you. Peak is. When a 2 GB box runs out of memory, the kernel picks a process and kills it, and the record lands in dmesg -T or journalctl -k:

Out of memory: Killed process 4211 (chrome) total-vm:2318936kB, anon-rss:1418324kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:3540kB oom_score_adj:0

Your agent never sees that line. It sees a browser that stopped answering, usually as a Playwright error such as page.goto: Page crashed or a closed target. Nothing in that error mentions memory, which is why the OOM (out of memory) killer is the first thing to check when an agent fails at random on a small box. Sizing for the peak is the same exercise as picking RAM and CPU for an agent VPS.

Install the Moli binary, pinned to one version

The project publishes a shell installer and prebuilt tarballs on GitHub releases. As of August 2026 the current release is 1.0.1, published on 18 August 2026. The benchmark figures quoted in this guide were measured by the project on 0.1.1, so treat them as the rough shape of the engine rather than a promise about the build you install.

Pin the version. An installer that always resolves latest moves your agent onto a different browser engine at the next rebuild, and a change in browser behaviour is the kind of change you want to schedule rather than discover.

The shell installer is the quick way in, and it is worth reading before it is worth running.

curl --proto '=https' --tlsv1.2 -fsSL \
  -o /tmp/moli-installer.sh \
  https://github.com/lexmount/moli/releases/download/v1.0.1/moli-installer.sh
less /tmp/moli-installer.sh
sh /tmp/moli-installer.sh

Read the script before you run it. It is short. It picks an archive from your uname -m, then unpacks a single binary into ~/.local/bin. On x86_64 it takes moli-x86_64-unknown-linux-gnu.tar.gz, and on an Arm server it takes the aarch64 archive, so both Arm and x86 VPS plans are covered. Set MOLI_INSTALL_DIR to install somewhere else. Notice what it resolves for the version: the newest release, not the tag you fetched the script from. That is fine for a first look and wrong for a rebuild you want to be repeatable.

So for anything permanent, do by hand what the installer does and name the exact archive yourself. This is also how you put the binary somewhere a system service can reach it, and it saves you piping a downloaded script into a shell.

cd /tmp
curl --proto '=https' --tlsv1.2 -fsSLO \
  https://github.com/lexmount/moli/releases/download/v1.0.1/moli-x86_64-unknown-linux-gnu.tar.gz
mkdir -p moli-pkg
tar -xzf moli-x86_64-unknown-linux-gnu.tar.gz -C moli-pkg --strip-components=1
sudo install -m 0755 moli-pkg/moli /usr/local/bin/moli
moli --version

moli --version printing the version you pinned is the whole check. moli: command not found straight after the installer means the install directory is not on your PATH, and the installer prints a line naming the directory you need to add.

One shot extraction with moli fetch

Many of the jobs an agent gives a browser are "load this URL and tell me what it says". That needs no server at all. moli fetch starts the engine, loads one page, writes one artifact to standard output and exits, so nothing holds memory between calls.

moli fetch --dump markdown --wait-until networkidle https://example.com
moli fetch --dump semantic_tree_text --wait-selector "main" https://example.com
moli fetch --dump json --wait-until networkidle https://example.com > page.json

The first prints the page as Markdown, beginning with # Example Domain. Markdown is the cheapest thing to hand a model, because it drops the markup and keeps the text. semantic_tree_text keeps roles and structure, which is what you want on a navigation-heavy page where the links matter as much as the prose. --dump json carries the HTTP status and the request trace, so reach for it when a fetch comes back empty and you need to know why.

The wait strategy decides whether you get content or an empty shell. --wait-until networkidle returns once the network goes quiet. --wait-until domstable returns once the DOM stops changing, which is the better choice on a page that polls in the background and so never goes quiet at all. --wait-selector waits for one selector you name. It is the only strategy that knows anything about the page you are fetching, which makes it the most reliable one whenever you know the target.

Screenshots and PDF need real layout, and layout is off by default:

moli fetch --layout --dump screenshot https://example.com > page.png
moli fetch --layout --dump screenshot_full https://example.com > full-page.png
moli fetch --layout --dump pdf https://example.com > page.pdf

The README names the default layout policy LayoutPolicy::Mock: geometry is faked and nothing is painted, because layout and paint are the expensive half of a browser. That default is the reason the memory figures above look the way they do. It also means an empty PNG is usually a missing --layout flag rather than a broken page.

For URLs your agent found rather than URLs you chose, add --block-private-networks. An agent that follows links it read on a page can be steered into fetching http://169.254.169.254/ for cloud instance credentials, or a database port on localhost that was never meant to face the web. That flag refuses navigation into private address space, and --block-cidrs narrows it further. When the job is crawling rather than reading one page, the shape of that pipeline is covered in self-hosted Firecrawl alternatives, and the step before it, finding URLs at all, is covered in a SearXNG-backed search skill for agents.

Point an agent at Moli over CDP

For an agent that navigates and clicks across many steps, run the server instead.

moli serve --host 127.0.0.1 --port 9222

127.0.0.1 and port 9222 are the defaults, so a bare moli serve already binds to loopback only. Write both out anyway in anything permanent, because the next person to read your service file should not have to remember what the default was.

Check the server before you connect a client to it:

curl -s http://127.0.0.1:9222/json/version

A healthy server answers with a JSON object holding a webSocketDebuggerUrl field, and that URL is what a CDP client attaches to. curl: (7) Failed to connect to 127.0.0.1 port 9222: Connection refused means nothing is listening, so read the terminal you started it in, or journalctl -u moli -n 50 once it is a service. /json/list lists the open targets and /json/protocol lists the domains this build implements, which is how you find out whether a CDP method you depend on exists here.

Playwright attaches to that endpoint instead of launching a browser of its own:

import { chromium } from "playwright";

const browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();

await page.goto("https://example.com");
console.log(await page.locator("body").innerText());

await browser.close();

The line that matters is connectOverCDP, not chromium.launch(). There is no child Chromium process here, so executablePath and the usual container flags such as --no-sandbox have nothing to act on. Proxy, cookie and user-agent settings go to the Moli server as its own flags for the same reason. Expect selected CDP coverage rather than the whole Chrome protocol: an explicit unsupported-method error is a boundary of the engine, not a bug in your code.

Two server flags decide what the agent can do. --layout turns on real geometry, which is what coordinate clicks and screenshots need. --resource fetches the optional images, fonts and media, and it costs bandwidth and memory on every page load, so leave it off until a page proves it needs them. --profile-dir persists cookies and storage between runs, and without it every run is disposable.

ChartOne agent episode, Moli against Chromium, figures published by the Moli project
The data behind this chart
[
  {
    "engine": "Moli",
    "cdp_ready_ms": 34.85,
    "peak_pss_mib": 102.46,
    "processes": 1
  },
  {
    "engine": "Chromium",
    "cdp_ready_ms": 169.37,
    "peak_pss_mib": 348.82,
    "processes": 11
  }
]

On the project's sample agent workload, Moli accepted a CDP connection after 34.85 ms against 169.37 ms for Chromium, at a peak PSS (proportional set size, memory counted with shared pages split between the processes sharing them) of 102.46 MiB against 348.82 MiB. The structural difference is the last column: 1 process against 11. One process is one thing for systemd to supervise and one cgroup to cap, which is what makes the next section short.

Run moli serve as a systemd service on loopback

Run the server as a service when an agent needs a browser waiting for it. Keep using moli fetch per URL when it does not, because an idle server still holds its memory.

Do not put port 9222 on a public interface. CDP has no authentication step of any kind. Anyone who can reach that port can drive the browser and read whatever the browser can reach, including any cookies in your profile directory. Keep it on 127.0.0.1. Reach it from another machine through an SSH tunnel (ssh -L 9222:127.0.0.1:9222 user@your-vps) or over a private VPN interface, and let the agent connect to http://127.0.0.1:9222 on its own side of that tunnel.

Create a service user, then the unit file:

sudo useradd --system --home-dir /var/lib/moli --shell /usr/sbin/nologin moli

Write /etc/systemd/system/moli.service:

[Unit]
Description=Moli headless browser CDP server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=moli
Group=moli
ExecStart=/usr/local/bin/moli serve --host 127.0.0.1 --port 9222 --profile-dir /var/lib/moli/profile --block-private-networks
Restart=on-failure
RestartSec=2
StateDirectory=moli
MemoryAccounting=yes
MemoryMax=768M
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now moli.service
systemctl status moli.service
curl -s http://127.0.0.1:9222/json/version

systemctl status should show active (running), and the curl should return the discovery JSON. ProtectSystem=strict mounts the whole filesystem read-only for this unit, which is why StateDirectory=moli is not optional here: it creates /var/lib/moli, owned by the service user, and makes that one path writable. A unit that starts and then dies with a permission error in journalctl -u moli is nearly always trying to write somewhere ProtectSystem has just made read-only, so move that path under the state directory.

MemoryMax=768M is what makes this safe to run beside your application. The unit gets its own cgroup, and when that cgroup goes over its limit the kernel kills something inside it and leaves the rest of the box alone. The journal records it:

moli.service: A process of this unit has been killed by the OOM killer.

Read that line as a sizing signal. Either the pages are heavier than you planned for, or the limit is too low. Set the number from a measurement of your own pages, which is the next section. The same accounting flags fence in any other service on the box, and capping memory and CPU with systemd works through the rest of them.

Measure peak memory yourself

The published figures came from someone else's hardware and someone else's pages. Peak memory decides whether your box survives, and peak depends entirely on what you load. Measure before you size.

For one-shot fetching, use the time binary, which reports much more than the shell builtin of the same name:

sudo apt update && sudo apt install -y time
/usr/bin/time -v moli fetch --dump markdown --wait-until networkidle https://example.com > /dev/null

The output ends with a block of resource statistics that includes Maximum resident set size (kbytes). Divide it by 1024 for MiB. Run it over ten pages your agent really visits rather than over example.com, and keep the worst result instead of the average, because the OOM killer reacts to peaks.

For the service, read the counter the kernel already keeps for its cgroup:

cat /sys/fs/cgroup/system.slice/moli.service/memory.peak
systemd-cgtop -m

memory.peak is a byte count, and it is the high-water mark since the unit last started, so a restart resets it. That figure is what MemoryMax has to sit above, with room on top for the heaviest page you have not visited yet. systemd-cgtop -m shows live usage per unit, which is the fastest way to see which service on the box is the expensive one today.

Where does Moli fail, and when do you still need Chrome?

The project also runs a benchmark of 1,308 comparable browser automation tasks and publishes the score for several engines.

ChartLexbench headless browser suite, 1,308 tasks, figures published by the Moli project
The data behind this chart
[
  {
    "engine": "Chrome",
    "success_rate_pct": 99.85
  },
  {
    "engine": "Moli 0.1.1",
    "success_rate_pct": 81.88
  },
  {
    "engine": "Kitesurf",
    "success_rate_pct": 62.08
  },
  {
    "engine": "Lightpanda",
    "success_rate_pct": 53.29
  },
  {
    "engine": "Obscura",
    "success_rate_pct": 44.88
  }
]

Across those 5 engines, Moli 0.1.1 completed 81.88 percent of the tasks and Chrome, the reference engine, completed 99.85 percent. This is the project scoring itself on its own suite, so read it as a claim rather than as an independent result.

The practical reading is simple. Roughly one task in five failed on Moli that Chrome completed. If your agent visits a fixed set of pages you control, that ratio tells you very little, because your pages either work or they do not and you can find out this afternoon. If your agent browses the open web, it is a real failure rate you have to design around.

What fails is predictable from the project's stated scope.

  • Applications that draw their interface into a Canvas element instead of the DOM, because Canvas fidelity is explicitly out of scope
  • Anything that needs WebGL or GPU compositing, because there is no GPU compositor
  • DRM-protected video and demanding media playback
  • Visual tests that assert pixel-exact screenshots against Chrome, since parity with Chrome is not a goal

The other figure the project quotes, one full run passing 1.612 million web platform tests, is a statement about standards coverage. It is not a promise about the sites your agent will visit. A page can use nothing but well-supported standards and still fail on a bot check, and no engine score covers that.

So keep the fallback in the design. Send every URL to Moli first. When a page comes back empty, or a selector never appears, retry that one URL with Playwright driving real Chrome, on a bigger machine or on a schedule where a 773 MiB process is affordable. Most agents spend most of their time on ordinary pages, so the small engine carries the volume and the expensive one carries the tail.

FAQ

Can Moli replace headless Chrome for my agent?

For reading pages, extracting text and ordinary clicking, usually yes. On the project's own benchmark of 1,308 tasks it completed 81.88 percent against Chrome's 99.85 percent, so about one task in five needs something Moli does not do. Canvas-rendered applications, WebGL and DRM video are the known gaps. Route those URLs to real Chrome rather than switching everything back.

How much RAM does Moli need on a VPS?

The project reports a median RSS of 73 MiB across a 192-URL crawl, and a peak PSS of 102.46 MiB on a sample agent episode, against 773 MiB median for headless Chrome. Those are their numbers on their pages. Measure yours with /usr/bin/time -v around a moli fetch call for one-shot use, or read /sys/fs/cgroup/system.slice/moli.service/memory.peak for the service, then set MemoryMax above the worst figure you see.

Is it safe to expose port 9222 to the internet?

No. CDP has no authentication, so anyone who can reach that port can drive your browser and read anything the browser can reach. Keep --host 127.0.0.1, and reach the endpoint from another machine through an SSH tunnel or over a private VPN interface. If you must bind another address, put it on a private interface and control access with the firewall.

Why is my screenshot empty, or my click landing on nothing?

Layout is off by default. The README names the default policy LayoutPolicy::Mock, so element geometry is not real and anything that depends on a box on the page has nothing to work with. Start the server with moli serve --layout, or add --layout to moli fetch, and the screenshot and coordinate paths start working. Missing images are a different flag: --resource.

Which version of Moli should I install?

Pin one, and record which. As of August 2026 the current release is 1.0.1, while the benchmark figures the project publishes were measured on 0.1.1, so the two are not interchangeable when you compare notes with someone else. Download that tag's moli-x86_64-unknown-linux-gnu.tar.gz and install the binary yourself rather than leaning on the shell installer, which resolves the newest release instead of the tag you fetched it from, then confirm with moli --version.