A self-hosted "cloud browser" for AI agents, designed to live on a Proxmox LXC on your home network. Headless Chromium with its DevTools Protocol (CDP) exposed over an authenticated reverse proxy on the LAN.
Drop-in target for browser-harness:
set BU_CDP_URL=http://<lxc-ip>:9223/<token> and every helper in the library —
new_tab, click_at_xy, http_get, the interaction-skills library — runs
against this Chromium instead of your laptop's.
Not for public-internet exposure. There is no TLS in front; the auth model is a pre-shared token in the URL path, sized for LAN trust. If you need a public endpoint, put cloudflared / a reverse proxy with mTLS in front yourself.
Three patterns are common today, and all have problems:
- Drive your laptop's Chrome via CDP. Fine until an agent jumps in while you're working in the same browser and clicks the wrong tab.
- Wrap a headless Chromium in an MCP server. Works for MCP clients (Claude Desktop, Cursor) but flattens harness-style libraries into N round-trips and one screenshot per action.
- Pay for a hosted cloud browser. Works, costs money, leaves your homelab idle.
This repo is the third pattern, self-hosted. CDP in, CDP out, on your LAN.
Two systemd services, one auth proxy, no MCP layer. Use it directly from any
CDP client (browser-harness, Puppeteer, Playwright-over-CDP, raw cdp-use).
If you want an MCP-facing surface on top of the same Chromium, run
qa-master-mcp alongside — that
project's job is the MCP translation; this project's job is the browser.
┌──── LAN ────┐ ┌──── LXC ────┐
│ agent or │ ws://lxc:9223/<token>/devtools/... │ cdp-auth │
│ browser- ├───────────────────────────────────────────>│ -proxy │
│ harness │ http://lxc:9223/<token>/json/version │ :9223 │
│ │ │ │ │
└─────────────┘ │ ▼ │
│ chromium │
│ headless │
│ :9222 loop │
└─────────────┘
chromium-headless.serviceruns Chromium with--remote-debugging-port=9222bound to127.0.0.1(loopback only — never directly exposed).cdp-auth-proxy.servicelistens on0.0.0.0:9223. It strips the/<token>/prefix, forwards HTTP and WebSocket traffic to Chromium, and rewriteswebSocketDebuggerUrlin JSON responses so clients keep talking through the proxy.- The token lives in
/etc/homelab-cloud-browser/token, generated at install time. Rotate it by overwriting the file andsystemctl restart cdp-auth-proxy.
Once accepted upstream, one command from the Proxmox shell:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelab-cloud-browser.sh)"Until then, see manual install below.
After install, the script prints:
- LXC IP
- The generated token
- The full
BU_CDP_URLvalue to copy into your shell
ssh root@<lxc-ip>
curl -fsSL https://raw.githubusercontent.com/zoexx/homelab-cloud-browser/main/install.sh | bashOr clone and run locally:
git clone https://github.com/zoexx/homelab-cloud-browser.git
cd homelab-cloud-browser
sudo ./install.shThe installer is idempotent. Re-running it preserves the existing token.
export BU_CDP_URL="http://192.168.1.117:9223/REPLACE_WITH_TOKEN"
browser-harness <<'PY'
new_tab("https://example.com")
wait_for_load()
print(page_info())
PYThat's it. No start_remote_daemon call needed — BU_CDP_URL is
browser-harness's documented escape hatch for arbitrary CDP endpoints, and
this server speaks the same /json/version discovery + WebSocket protocol
that Browser Use's cloud uses.
Each connecting WebSocket gets its own Chromium browser context server-side — separate tabs, separate cookies, fully isolated. Two clients can drive the LXC at once without seeing each other's tabs.
browser-harness already has the right knob: BU_NAME selects the
laptop-side daemon process. Each distinct BU_NAME opens its own
WebSocket to this proxy → its own context. Reuse the same BU_NAME to
keep state across invocations in the same shell; use different BU_NAME
per agent or task to get independence.
# These two won't see each other's tabs.
BU_NAME=satisfaitla browser-harness <<'PY'
new_tab("https://satisfaitla-staging.example.com")
PY
BU_NAME=research browser-harness <<'PY'
new_tab("https://news.ycombinator.com")
PYLifecycle: a context lives as long as its WebSocket is open. When the WebSocket closes (daemon stops, network drops, laptop sleeps past the 30-second heartbeat), the context is disposed and its tabs are gone. There is no on-disk persistence per lane.
GET /<token>/health returns proxy state as JSON:
curl -s "$BU_CDP_URL/health" | jq .
# {
# "uptime_s": 12345,
# "last_cdp_ok_age_s": 2,
# "contexts": 3,
# "ws_open": 3,
# "ws_total_opened": 17
# }uptime_s is the proxy's uptime, last_cdp_ok_age_s is seconds since the
last successful upstream CDP call (null if none yet), contexts /
ws_open track currently isolated lanes, ws_total_opened is a cumulative
counter useful for spotting churn.
TOKEN="..."
curl -s "http://192.168.1.117:9223/$TOKEN/json/version" | jq .
# {
# "Browser": "HeadlessChrome/...",
# "webSocketDebuggerUrl": "ws://192.168.1.117:9223/<token>/devtools/browser/<uuid>",
# ...
# }The webSocketDebuggerUrl already points back through the proxy with the
token prefix, so any CDP client that does /json/version discovery will work
without further configuration.
/etc/homelab-cloud-browser/cloud-browser.env controls the proxy and the
Chromium wrapper:
| Var | Default | Notes |
|---|---|---|
CDP_PROXY_BIND |
0.0.0.0 |
Bind address for the proxy. Use 127.0.0.1 to require an SSH tunnel. |
CDP_PROXY_PORT |
9223 |
LAN-facing proxy port. |
CHROMIUM_DEBUG_PORT |
9222 |
Internal loopback port. No reason to change this. |
CHROMIUM_USER_DATA_DIR |
/var/lib/homelab-cloud-browser/profile |
Persistent profile. |
CHROMIUM_WINDOW_SIZE |
1280,800 |
Default viewport. |
CHROMIUM_EXTRA_ARGS |
(empty) | Extra flags appended to chromium command. |
If Chromium restarts on the LXC — systemd will do this automatically on
crash, or after systemctl restart chromium-headless — any
browser-harness daemon already running on your laptop holds a now-dead
WebSocket. The next call from that daemon will hang until its read
timeout. Bounce the laptop-side daemon by killing the process
(pkill -f browser_harness.daemon) or by starting a fresh browser-harness
invocation with a different BU_NAME. The new daemon will open a fresh
WebSocket to the proxy and get a fresh isolated context.
- Token-in-path bearer. Anyone who learns the token gets full browser control: navigate any URL, execute arbitrary JS, read cookies, attach debuggers. Treat it like an SSH private key.
- LAN-only by default. No TLS. Do not forward
:9223from your router. - No per-client isolation in this layer. Two clients sharing the same
token share the same Chromium profile and can see each other's tabs. If you
need isolation, run multiple LXCs or use
qa-master-mcp(which keys per MCP session) in front. - Chromium runs with
--no-sandbox. Safe inside an unprivileged LXC (the LXC itself is the sandbox), but don't render attacker-controlled URLs on this profile if you store sensitive cookies in it. Use a separate LXC with a separate token for adversarial sites.
- Not a public SaaS. No TLS, no rate limiting, no metering.
- Not multi-tenant. Multiple parallel agents are fine; multiple distrustful users are not.
- Not an MCP server. If your client only speaks MCP, run
qa-master-mcpagainst this endpoint (pointBU_CDP_URLat it instead of at a local Chromium). - Not a Playwright wrapper. CDP only. Playwright clients that can speak CDP
(most of them, via
chromium.connectOverCDP) will work fine.
deploy/
chromium-headless wrapper that builds the chromium command line
chromium-headless.service systemd unit running the wrapper as `bcb` user
cdp-auth-proxy.py the auth proxy (aiohttp)
cdp-auth-proxy.service systemd unit
cloud-browser.env.example env template
community-scripts/
ct/homelab-cloud-browser.sh entry script for community-scripts
install/homelab-cloud-browser-install.sh inner installer
install.sh canonical installer (manual-install path)
MIT.