Freestyle Docs

Freestyle / Guides

How to Run Chromium in a Sandbox

Run Chrome-family browsers headless or headed, connect over CDP, watch through noVNC, and give a computer-use agent control.

This guide builds reusable Chrome-family browser snapshots for automation and computer-use agents. Use the Debian chromium package, from the Chromium project, when you want the fastest distro-native install. Use Google Chrome Stable when you need Google’s official build or Chrome-specific behavior. Both run in Freestyle as normal Linux processes.

Start headless for scraping, screenshots, tests, and DOM-aware automation. Add Xvfb, noVNC, and a window manager when a human needs to watch or take over, or when an agent needs a visible browser. The headed and headless variants use the same Chrome DevTools Protocol (CDP) control channel.

The base VM is intentionally small, and browser installs pull many GTK, font, and media packages. Install them in a background systemd unit and poll a marker file instead of leaving one long vm.exec() request open. That keeps setup alive even when apt takes several minutes.

Install the SDK

pnpm add freestyle playwright-core
bun add freestyle playwright-core
npm install freestyle playwright-core
yarn add freestyle playwright-core

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Run Long Setup Steps Safely

Use one helper for every heavyweight install. It writes a root-only script, starts it as a transient systemd unit, then polls /tmp/<name>.done. The polling calls are short, so they do not hit client-side HTTP timeouts while apt is unpacking browser dependencies.

async function runSetup(vm, name: string, script: string) {
  const scriptPath = `/root/setup-${name}.sh`;
  const donePath = `/tmp/setup-${name}.done`;
  const unit = `setup-${name}`;

  await vm.fs.writeTextFile(
    scriptPath,
    `#!/bin/sh
set -eux
export DEBIAN_FRONTEND=noninteractive
${script}
touch ${donePath}
`,
  );

  await vm.exec(
    `chmod +x ${scriptPath} && rm -f ${donePath} && ` +
      `systemd-run --unit=${unit} --collect ${scriptPath}`,
  );

  for (let i = 0; i < 90; i++) {
    const probe = await vm.exec(
      `if test -f ${donePath}; then echo DONE; else ` +
        `systemctl show ${unit} -p ActiveState -p Result --no-pager; ` +
        `journalctl -u ${unit} --no-pager -n 20; fi`,
    );
    const output = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
    if (output.includes("DONE")) return;
    if (output.includes("Result=oom-kill") || output.includes("Result=exit-code")) {
      throw new Error(output);
    }
    await new Promise((resolve) => setTimeout(resolve, 10_000));
  }

  throw new Error(`${unit} did not finish`);
}

If you are installing a full desktop, audio stack, and browser at once, split the work into stages. A single all-in-one install can spike memory; staged installs keep the default VM usable.

Build a Chromium Snapshot

Chromium is available from Debian on the Freestyle base image. Install it with fonts and certificates, verify the binary, snapshot the VM, then delete the builder.

import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create({
  slug: "chromium-builder",
  idleTimeoutSeconds: null,
});

await runSetup(
  builder,
  "chromium",
  `apt-get update -qq
apt-get install -y -qq --no-install-recommends \
  chromium ca-certificates curl file fonts-liberation`,
);

const version = await builder.exec("chromium --version");
console.log(version.stdout.trim()); // Chromium 149...

const { snapshotId } = await builder.snapshot();
await builder.delete();

Every VM created from snapshotId now has /usr/bin/chromium ready. Browser installs are the slow part; snapshot once and fan out from that image.

Run Headless Browser Jobs

Create a VM from the snapshot and call Chromium directly with Chrome Headless flags. Running as root needs --no-sandbox; --disable-dev-shm-usage avoids relying on a small /dev/shm. DBus warnings on stderr are normal in the minimal image and do not mean the page failed.

const { vm } = await freestyle.vms.create({ slug: "chromium-headless", snapshotId });

const chrome = "/usr/bin/chromium";
const flags = "--headless=new --no-sandbox --disable-dev-shm-usage";

const dom = await vm.exec(
  `${chrome} ${flags} --dump-dom https://example.com | grep -i 'Example Domain'`,
);
console.log(dom.statusCode); // 0

await vm.exec(
  `${chrome} ${flags} --window-size=1280,800 ` +
    `--screenshot=/tmp/example.png https://example.com`,
);

const png = await vm.exec("base64 -w0 /tmp/example.png");

Bring the screenshot back to your local process if you need it:

import { writeFile } from "node:fs/promises";

await writeFile("example.png", Buffer.from(png.stdout, "base64"));

Start a Local DevTools Endpoint

For programmatic control, run Chromium with the Chrome DevTools Protocol (CDP) enabled. Bind it to 127.0.0.1 inside the VM unless you are deliberately putting a reverse proxy in front of it. CDP grants full control of the browser profile.

await vm.fs.writeTextFile(
  "/etc/systemd/system/chromium-cdp.service",
  `[Unit]
Description=Headless Chromium DevTools

[Service]
Environment=HOME=/root
ExecStart=/usr/bin/chromium --headless=new --no-sandbox --disable-dev-shm-usage --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --remote-allow-origins=* about:blank
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

await vm.exec("systemctl daemon-reload && systemctl enable --now chromium-cdp");

const ready = await vm.exec(
  "for i in $(seq 1 30); do curl -fsS http://127.0.0.1:9222/json/version && exit 0; sleep 1; done; exit 1",
);
console.log(ready.stdout);

Do not map a Freestyle domain straight to port 9222 for off-VM controllers. Chrome protects DevTools with Host-header checks, so the response may be an HTML error instead of JSON. Use the CDP reverse proxy below when Playwright or a computer-use process needs to connect from outside the VM.

Use Google Chrome Stable

Google Chrome Stable works too. Install the official .deb in its own setup stage and swap the binary path to /usr/bin/google-chrome. The same headless, headed, and CDP flags apply.

const { vm: chromeBuilder } = await freestyle.vms.create({
  slug: "google-chrome-builder",
  idleTimeoutSeconds: null,
});

await runSetup(
  chromeBuilder,
  "google-chrome",
  `apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates curl file fonts-liberation
curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt-get install -y -qq --no-install-recommends /tmp/google-chrome.deb`,
);

const chromeVersion = await chromeBuilder.exec("google-chrome --version");
console.log(chromeVersion.stdout.trim()); // Google Chrome 149...

const { snapshotId: googleChromeSnapshotId } = await chromeBuilder.snapshot();
await chromeBuilder.delete();

Then run it like Chromium:

const { vm: chromeVm } = await freestyle.vms.create({
  slug: "google-chrome-headless",
  snapshotId: googleChromeSnapshotId,
});

await chromeVm.exec(
  "google-chrome --headless=new --no-sandbox --disable-dev-shm-usage " +
    "--dump-dom https://example.com | grep -i 'Example Domain'",
);

Other Chrome-Based Browsers

Brave, Microsoft Edge, and other Chromium-derived browsers follow the same shape: install the vendor package in a separate setup stage, find the binary path, then reuse the same flags and systemd units. Keep the install stage separate from the desktop and audio stages; vendor packages often pull their own dependency set.

Common binary paths:

  • chromium: /usr/bin/chromium
  • Google Chrome Stable: /usr/bin/google-chrome
  • Brave Browser: /usr/bin/brave-browser
  • Microsoft Edge Stable: /usr/bin/microsoft-edge

After the browser starts, the rest of the stack is browser-agnostic: screenshots, CDP, Xvfb, VNC, noVNC, and PulseAudio all talk to a Chrome-family process through the same Linux interfaces.

Build a Headed Browser for Computer Use

A browser-only agent does not need a full GNOME desktop. Use the Chromium snapshot from above, then add a small visible stack:

  • Xvfb owns the virtual display.
  • Openbox manages the browser window.
  • x11vnc exports the display.
  • noVNC lets a human watch or take over in a browser.
const { vm: headedBuilder } = await freestyle.vms.create({
  slug: "computer-use-browser-builder",
  snapshotId,
  idleTimeoutSeconds: null,
});

await runSetup(
  headedBuilder,
  "headed-desktop",
  `apt-get update -qq
apt-get install -y -qq --no-install-recommends \
  xvfb openbox x11vnc novnc websockify \
  xauth x11-utils dbus-x11 python3 nginx`,
);

To use Google Chrome Stable instead, start headedBuilder from googleChromeSnapshotId and replace /usr/bin/chromium with /usr/bin/google-chrome in the browser service below.

Add the Visible Display Services

Add one systemd service for each display layer:

await headedBuilder.fs.writeTextFile(
  "/etc/systemd/system/xvfb.service",
  `[Unit]
Description=Virtual X display

[Service]
ExecStart=/usr/bin/Xvfb :0 -screen 0 1440x900x24 -nolisten tcp
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

await headedBuilder.fs.writeTextFile(
  "/etc/systemd/system/openbox.service",
  `[Unit]
Description=Openbox window manager
After=xvfb.service
Requires=xvfb.service

[Service]
Environment=DISPLAY=:0
Environment=HOME=/root
ExecStartPre=/bin/bash -lc 'for i in {1..30}; do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/bin/openbox
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

await headedBuilder.fs.writeTextFile(
  "/etc/systemd/system/x11vnc.service",
  `[Unit]
Description=VNC server for the headed browser
After=xvfb.service
Requires=xvfb.service

[Service]
ExecStart=/usr/bin/x11vnc -display :0 -forever -shared -nopw -localhost -rfbport 5900
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

await headedBuilder.fs.writeTextFile(
  "/etc/systemd/system/novnc.service",
  `[Unit]
Description=noVNC web client
After=x11vnc.service
Requires=x11vnc.service

[Service]
ExecStart=/usr/bin/websockify --web /usr/share/novnc 6080 localhost:5900
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

Run Chromium on that display and keep CDP bound to loopback:

await headedBuilder.fs.writeTextFile(
  "/etc/systemd/system/chromium-headed.service",
  `[Unit]
Description=Chromium on the virtual display
After=xvfb.service openbox.service
Requires=xvfb.service

[Service]
Environment=DISPLAY=:0
Environment=HOME=/root
ExecStartPre=/bin/bash -lc 'for i in {1..30}; do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/bin/bash -lc 'mkdir -p "$HOME/.config/chromium-headed"; rm -f "$HOME/.config/chromium-headed"/Singleton*'
ExecStart=/usr/bin/chromium --no-sandbox --disable-dev-shm-usage --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --remote-allow-origins=* --user-data-dir=/root/.config/chromium-headed --window-size=1440,900 --window-position=0,0 https://example.com
Restart=always

[Install]
WantedBy=multi-user.target
`,
);

--remote-allow-origins=* lets WebSocket clients connect through the proxy in the next section. Keep Chrome itself on 127.0.0.1; CDP is an unauthenticated browser-control API.

Add a CDP Reverse Proxy

Chrome validates the DevTools Host header, so mapping a domain directly to port 9222 can return an HTML error instead of /json/version. Put nginx in front of CDP, rewrite Host to the loopback origin, and preserve WebSocket upgrades:

await headedBuilder.exec("rm -f /etc/nginx/sites-enabled/default");

await headedBuilder.fs.writeTextFile(
  "/etc/nginx/sites-available/chromium-cdp",
  `server {
  listen 9333;

  location / {
    proxy_pass http://127.0.0.1:9222;
    proxy_http_version 1.1;
    proxy_set_header Host 127.0.0.1:9222;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_buffering off;
  }
}
`,
);

await headedBuilder.exec(
  "ln -sf /etc/nginx/sites-available/chromium-cdp " +
    "/etc/nginx/sites-enabled/chromium-cdp",
);

Start the display, browser, VNC, noVNC, and proxy. Wait for the human viewer, CDP discovery endpoint, and visible browser window before snapshotting:

await headedBuilder.exec(
  "systemctl daemon-reload && systemctl enable --now " +
    "xvfb openbox chromium-headed x11vnc novnc nginx",
);

const headedReady = await headedBuilder.exec(
  `for i in $(seq 1 45); do
  curl -fsS http://127.0.0.1:6080/vnc.html >/dev/null &&
  curl -fsS http://127.0.0.1:9333/json/version >/tmp/cdp.json &&
  DISPLAY=:0 xwininfo -root -tree | grep -E 'Chromium|Google Chrome|example' &&
  exit 0
  sleep 1
done
journalctl -u chromium-headed -u novnc -u nginx --no-pager -n 120
exit 1`,
);

if (headedReady.statusCode !== 0) {
  throw new Error(`${headedReady.stdout}\n${headedReady.stderr}`);
}

const { snapshotId: headedSnapshotId } = await headedBuilder.snapshot();
await headedBuilder.delete();

Map Human and Agent Domains

Use one domain for noVNC and one for CDP:

const { vm: browserVm, vmId } = await freestyle.vms.create({
  slug: "computer-use-browser",
  snapshotId: headedSnapshotId,
  idleTimeoutSeconds: null,
});

const vncDomain = `browser-vnc-${crypto.randomUUID().slice(0, 8)}.style.dev`;
const cdpDomain = `browser-cdp-${crypto.randomUUID().slice(0, 8)}.style.dev`;

await freestyle.domains.mappings.create({
  domain: vncDomain,
  vmId,
  vmPort: 6080,
});
await freestyle.domains.mappings.create({
  domain: cdpDomain,
  vmId,
  vmPort: 9333,
});

console.log(
  `watch: https://${vncDomain}/vnc.html?` +
    "autoconnect=true&reconnect=true&resize=scale&path=websockify",
);
console.log(`cdp: https://${cdpDomain}/json/version`);

The noVNC tab and the agent control the same headed browser. A Playwright click appears in the viewer, and manual input through noVNC changes the page that Playwright sees.

Connect with Playwright

Chrome reports its internal WebSocket URL as ws://127.0.0.1:9222/.... Replace that origin with the public CDP domain and use wss://:

import { chromium } from "playwright-core";

const version = await fetch(`https://${cdpDomain}/json/version`).then((r) => {
  if (!r.ok) throw new Error(`CDP discovery failed: ${r.status}`);
  return r.json() as Promise<{ webSocketDebuggerUrl: string }>;
});

const wsUrl = version.webSocketDebuggerUrl.replace(
  "ws://127.0.0.1:9222",
  `wss://${cdpDomain}`,
);

const browser = await chromium.connectOverCDP(wsUrl);
const context = browser.contexts()[0];
if (!context) throw new Error("Chromium has no default context");

const page = context.pages()[0] ?? await context.newPage();
await page.goto("https://example.com", {
  waitUntil: "domcontentloaded",
});

console.log(await page.title()); // Example Domain
await page.screenshot({ path: "browser.png" });
await page.mouse.click(400, 300);
await page.keyboard.type("hello from the sandbox");

await browser.close();

Closing the Playwright connection does not close Chromium. The browser keeps running under systemd with its tabs, cookies, and profile intact.

Connect from a Serverless Function

The VM owns the long-lived browser. A short-lived function can establish a new CDP connection, perform one bounded group of actions, and disconnect:

import { chromium } from "playwright-core";

export async function runBrowserTask(cdpDomain: string) {
  const version = await fetch(`https://${cdpDomain}/json/version`).then((r) => {
    if (!r.ok) throw new Error(`CDP discovery failed: ${r.status}`);
    return r.json() as Promise<{ webSocketDebuggerUrl: string }>;
  });

  const wsUrl = version.webSocketDebuggerUrl.replace(
    "ws://127.0.0.1:9222",
    `wss://${cdpDomain}`,
  );

  const browser = await chromium.connectOverCDP(wsUrl);
  try {
    const context = browser.contexts()[0];
    if (!context) throw new Error("Chromium has no default context");

    const page = context.pages()[0] ?? await context.newPage();
    await page.goto("https://example.com", {
      waitUntil: "domcontentloaded",
    });

    return {
      title: await page.title(),
      screenshot: await page.screenshot({ type: "png" }),
    };
  } finally {
    await browser.close();
  }
}

Do not keep browser, context, or page in global variables and assume they will survive between function invocations. The function is stateless; the Freestyle VM preserves the browser session.

Give the Agent Browser Tools

Wrap Playwright in the narrow tool surface your model needs:

const computer = {
  async screenshot() {
    return await page.screenshot({ type: "png", fullPage: false });
  },
  async click(x: number, y: number) {
    await page.mouse.click(x, y);
  },
  async type(text: string) {
    await page.keyboard.type(text);
  },
  async navigate(url: string) {
    await page.goto(url, { waitUntil: "domcontentloaded" });
  },
  async text() {
    return await page.locator("body").innerText();
  },
};

That gives a computer-use agent the usual observe, click, type, navigate, and inspect loop. Keep VM deletion and domain lifecycle outside the model’s tool set unless the agent is intentionally responsible for infrastructure.

Use the GNOME desktop guide when the agent must operate native applications or cross application boundaries.

Choose Headless or Headed

Use headless Chromium when no person needs to watch and the task is DOM-aware. It is smaller and does not need X, VNC, or a window manager.

Use the headed noVNC plus CDP stack when you need visual debugging, manual takeover, sites that behave differently with a visible window, or screenshot-based computer use.

esc