Skip to content

Cookbook: live browser

01 / Live service

Most environments only need a warm ready state — imports resolved, caches hot, so a fork skips the install/import tax. A live-service environment goes one step further: a long-running process — here a browser — is started at bake time and left running, so the ready-state snapshot captures it alive. Every fork then wakes with the browser already up, and a rollout attaches to it instead of launching it.

This is the reference recipe for a browser template. Use it as a template for any live-service environment.

bake: boot guest → run warm command → SNAPSHOT (browser is running) → publish
fork: restore snapshot → browser is already alive → rollout attaches

The warm command must (1) start the browser detached so it survives the command returning, and (2) block until the browser is actually serving CDP, so the snapshot is taken only once it is ready — not merely spawned. See the warm-command contract.

The whole recipe is one baked script, /usr/local/bin/collimate-warm. Five details matter, and each is a common way to get this wrong:

#!/bin/sh
set -e
# 1. Bring loopback up. At bake time the guest's `lo` is not yet up; the CDP
# server binds to it, so without this the endpoint is unreachable.
ip link set lo up 2>/dev/null || true
# 2. Resolve Chromium's real binary via Playwright — never hard-code the path,
# it moves between releases (e.g. .../chromium-1228/chrome-linux64/chrome).
CH=$(python3 -c "from playwright.sync_api import sync_playwright as x; p=x().start(); print(p.chromium.executable_path); p.stop()")
# 3. Launch DETACHED (setsid) so it outlives this script and lands in the snapshot.
# Chromium ≥132 has ONLY the new headless mode: --remote-debugging-address is
# ignored and the CDP server always binds 127.0.0.1:9222. Don't fight it with
# bind flags — publish the endpoint with a relay (next step).
# --remote-allow-origins=* is REQUIRED for remote CDP: since Chromium 111 the
# DevTools WebSocket returns 403 for any handshake with an Origin header not on
# the allow-list, and every remote client (Playwright connect_over_cdp) sends
# one. Without it, remote attach fails; in-guest cdp-nav is unaffected.
setsid "$CH" --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
--remote-debugging-port=9222 --remote-allow-origins=* \
--user-data-dir=/tmp/cdp-profile about:blank \
>/var/log/cdp.log 2>&1 < /dev/null &
# 4. Publish the loopback-only CDP endpoint on the guest NIC (10.0.2.15) so
# connect-from-outside (`exposePorts` → per-fork DNAT → NIC) reaches it.
# cdp-relay is a ~40-line baked stdlib TCP relay: NIC:9222 → 127.0.0.1:9222,
# detached like Chromium so it lives in the snapshot, retrying its bind while
# the NIC comes up. Version-proof: no dependency on Chromium bind behavior.
setsid /usr/local/bin/cdp-relay 10.0.2.15:9222 127.0.0.1:9222 \
>/var/log/cdp-relay.log 2>&1 < /dev/null &
# 5. Block until CDP actually answers, so the snapshot captures a READY browser.
for i in $(seq 1 60); do
if python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9222/json/version', timeout=1)" 2>/dev/null; then
echo cdp-ready; exit 0
fi
sleep 1
done
echo cdp-timeout >&2; exit 1

Point the template's ready command at it when you bake:

from collimate_rl import Ready, Template, connect
client = connect() # a col_pro_* key via COLLIMATE_API_KEY
handle = Template.bake(
client,
name="browser",
image="ghcr.io/you/browser@sha256:…", # pin by digest
ready=Ready(
cmd="/usr/local/bin/collimate-warm", # launch detached → block → exit 0
ports=[9222], # expose CDP for connect-from-outside
),
width=32,
)
handle.wait(timeout=600)

ports=[9222] is what makes 9222 reachable from a client (via cdp_url() / Remote browser); it also gives the template a guest NIC, so the warm relay's 10.0.2.15 address exists. Omit it and a fork is in-guest only — cdp-nav still works, but a remote connect_over_cdp mint returns 409 port_not_exposed.

Run this script at build time as a self-test, too, so a green build proves Chromium launches and CDP answers before the image is ever baked.

A rollout attaches to the already-running browser — it does not launch one.

from collimate_rl import connect
client = connect(api_key="col_...")
# The rollout body runs inside the sandbox. It connects to the live browser that
# the ready-state snapshot is already running — no per-fork browser launch.
ROLLOUT = """
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp("http://127.0.0.1:9222")
page = browser.new_context().new_page() # isolated context per rollout
page.goto("https://example.com")
print(page.title())
browser.close() # closes the connection, not the browser
"""
sb = client.create_sandbox("browser")
result = client.exec(sb["id"], commands=[["python3", "-c", ROLLOUT]], timeout_seconds=60)
print(result["stdout"])
client.delete_sandbox(sb["id"])

Fan a warmed parent out across many rollouts with fork — every child inherits the same live browser:

# count is the per-call fan-out, capped by tier (Pro 32, Ent 64). For a wider
# group, call fork again (or use Session.fork_group to chunk it for you).
children = client.fork(sb["id"], count=32)["children"]
for child in children:
client.exec(child["id"], commands=[["python3", "-c", ROLLOUT]], timeout_seconds=60)

Give each rollout its own new_context() (fresh cookies/storage) and close the context, not the browser — the browser is the shared warm service.

Attaching to the pre-running browser skips the Chromium spawn a cold chromium.launch() pays. Two ways to attach, fastest last:

Measured in a browser sandbox on Collimate prod, in-guest, median of 3 runs (attach + navigate to a fixed page, so the number is attach cost, not network):

Attach pathCommandMedian attach + navigate
Raw CDP (cdp-nav)["cdp-nav", url]~235 ms
Playwright connect_over_cdp["python3", "-c", rollout]~1350 ms
  • Raw CDP — talk to ws://127.0.0.1:9222 with a stdlib WebSocket client and send Page.navigate directly. No Node driver, so attach-and-drive is ~235 ms — roughly 5.7× faster than Playwright. Reach for it when per-rollout latency is the bottleneck.
  • Playwright connect_over_cdp(...) — convenient (selectors, waits, screenshots), but almost all of its ~1.35 s is its Node driver booting each call. Still far faster than a cold chromium.launch().

The ready-state snapshot is a shared artifact, so it must never contain credentials — never bake cookies, tokens, or a logged-in profile into the image or the warm state. Two patterns keep auth per-tenant and per-fork:

  • Per-fork login + storage_state. Log in inside the rollout on first use and persist Playwright's storage_state to the fork's own disk (per-fork, never shared), so later steps in the same episode reuse the session:

    import os
    ctx = browser.new_context(
    storage_state="/tmp/state.json" if os.path.exists("/tmp/state.json") else None
    )
    # ...perform the login flow using a runtime-injected credential...
    ctx.storage_state(path="/tmp/state.json") # persist for subsequent steps in this fork
  • Runtime credential injection. Keep the credential itself out of your code: store it in Collimate's secret store and inject it at sandbox-create time, where it reaches the sandbox through the sealed credential proxy — never the artifact, never a log.

Browser networking is a Pro capability: demo sandboxes have no outbound network, so real navigation (and therefore login) needs a Pro key with egress enabled:

sb = client.create_sandbox("browser", egress={"allow": ["example.com"]})

See Per-fork egress for the full policy model.

Adapting to other live-service environments

Section titled “Adapting to other live-service environments”

The same warm command works for any long-running service — swap the launch line and the readiness probe, keep the shape:

ServiceLaunch (detached)Readiness probe
Jupyter kernelsetsid jupyter kernel …connection file / kernel_info over ZMQ
Postgressetsid pg_ctl start …pg_isready
Model serversetsid python -m your_server …GET /health returns 2xx

Keep it launch detached → block until serving → exit 0, and the service lands in the ready-state snapshot exactly like the browser.