Source: Dockerfile
Headless Browser (Chrome/Chromium) with a builtin web tool to interactive with the browser.
docker pull deaddev/browser
# or
docker pull ghcr.io/zigzagt/browser
linux/amd64, linux/arm64 — Playwright installs Chrome for Testing on amd64, Chromium on arm64.
| Tag | Content |
|---|---|
latest | Full browser + browser-bridge CDP proxy |
chrome | Alias for latest |
chromium | Alias for latest |
The image has a layered wrapper chain and a CDP proxy server:
| Path | Role |
|---|---|
/usr/local/bin/chrome | Entry point. It starts both Chrome and the bridge. You should call this to start chrome |
/usr/local/bin/chrome-launcher | Lower-level wrapper that generate launch arguments and launches the browser |
/usr/local/bin/chrome-raw | Symlink to the actual Chrome/Chromium binary |
/usr/local/etc/chrome-ua | User-Agent string to be used |
/opt/browser-bridge/ | Web based tool that allows access the chrome instance in the container. Default launches at port 6080 |
docker run -d --init --name browser --shm-size=1g \
-p 6080:6080 \
deaddev/browser
Open http://<host>:6080 to access the browser.
services:
browser:
init: true
image: deaddev/browser
shm_size: 1g
ports:
- "6080:6080"
environment:
CHROME_USER_DATA_DIR: /data # persist profile (cookies, sessions, extensions); omit for ephemeral
volumes:
- browser-data:/data
devices:
- /dev/dri:/dev/dri # GPU acceleration on Linux hosts (Intel/AMD)
volumes:
browser-data:
Drop the devices line if your host has no /dev/dri (macOS, Windows). The container falls back to bundled SwiftShader (CPU rendering) automatically.
The image ships with software rendering (SwiftShader) by default. To enable hardware acceleration on Linux hosts:
| Host | Steps |
|---|---|
| Linux + Intel iGPU | Mount /dev/dri. Mesa drivers and Intel VA-API drivers are pre-installed in the amd64 image. Verify with chrome://gpu in the viewer — WebGL Renderer should report your iGPU instead of SwiftShader. |
| Linux + AMD GPU | Mount /dev/dri. Mesa Vulkan/GL drivers handle AMD. |
| Linux + NVIDIA GPU | Mount /dev/dri is not enough. Install NVIDIA Container Toolkit on the host and add --gpus all to docker run. |
| macOS / Windows / arm64 hosts | Falls back to SwiftShader (CPU). No way to pass GPU into Linux containers via Docker Desktop. The arm64 image omits Mesa packages to stay small. |
Set executablePath to chrome in puppeteer/playwright.
FROM deaddev/browser
RUN npm install -g your-app
CMD ["your-app"]
const browser = await puppeteer.launch({ executablePath: 'chrome' });
When called with --* flags (as puppeteer/playwright do), chrome starts the bridge in the background then execs into the browser directly.
| Command | What it does |
|---|---|
chrome [...args] | Start Chrome + bridge in foreground (default CMD) |
chrome start-detached [...args] | Start Chrome + bridge in background |
chrome restart | Restart both, reuses saved args |
chrome stop | Stop both |
Append extra flags to any start command. Such as chrome [... your args].
docker run -d --init --shm-size=1g -p 6080:6080 \
deaddev/browser chrome --proxy-server=socks5://host:1080
The bridge exposes a Model Context Protocol (MCP) endpoint at /mcp so any MCP-capable agent can drive this browser as a tool. Streamable-HTTP transport, stateless.
Add to your agent's MCP config (Claude Code, Cursor, etc.):
{
"mcpServers": {
"browser": {
"type": "http",
"url": "https://<your-bridge-host>/mcp"
}
}
}
The MCP server also sends an instructions field at the initialize handshake with a full workflow guide; agents typically read it automatically. The table below is a summary.
Discovery and tab management:
| Tool | Purpose |
|---|---|
browser_list_tabs() | All tabs with id/url/title/mcpOwned/attention + cap counters. Call this first. |
browser_navigate(tabId, url) | Navigate an EXISTING tab. Does not consume an FIFO slot. Prefer this to opening new tabs. |
browser_open(url) | Create a new MCP-owned tab. Counts against the FIFO cap. |
browser_reload(tabId) | Reload a tab (CDP Page.reload). |
browser_close_tab(tabId) | Close any tab. Frees the MCP FIFO slot if it was MCP-owned. |
Reading pages (pick the right tool):
| Tool | Purpose |
|---|---|
browser_get_snapshot(tabId) | Primary. Compact accessibility-tree text with stable [uid=N] markers. Filter follows Puppeteer's interestingOnly — 5-10× denser than HTML. |
browser_get_text(tabId, selector?) | document.body.innerText (full, or scoped to a selector). Raw content, no structure. |
browser_get_html(tabId, path, maxDepth) | Last-resort single-element inspection. HTML is markup-heavy — prefer snapshot. |
browser_evaluate(tabId, expression) | Run JS in the page for reading only. NOT for clicks/typing/scrolling. |
browser_screenshot(tabId, fullPage?) | Visual confirmation only (base64 PNG). |
Interacting (trusted Chrome DevTools Protocol input):
| Tool | Purpose |
|---|---|
browser_click(tabId, at?, uid?, selector?, mouseButton?, doubleClick?, delayMs?, modifiers?, includeSnapshot?) | Trusted mouse click. Provide at: {uid}, at: {selector}, or at: {x, y}; alternatively provide uid or selector directly. Use only one targeting field. Reveals element targets automatically. A plain left click on an option without geometry selects it through native keyboard input. Supports left/middle/right buttons, double-click, and a press duration of 0–1000 milliseconds. |
browser_type(tabId, text, uid?, selector?, replace?, includeSnapshot?) | Trusted text input via Input.insertText. Reveals and clicks-to-focus first if uid/selector given; otherwise uses the focused field. replace defaults to false. replace: true selects all with Ctrl+A (Command+A on macOS) before typing; an empty string with replace: true clears the field. Otherwise inserts at the current caret/selection. |
browser_move_pointer(tabId, to, from?, mouseButton?, modifiers?, includeSnapshot?) | Provide to: {uid}, to: {selector}, or to: {x, y} as the destination. For dragging, also provide from and mouseButton: "left" (or "middle"/"right"). The call positions at from, presses, moves along a short interpolated path, and releases. |
browser_select(tabId, values, uid?, selector?, includeSnapshot?) | Provide uid or selector for a native <select>. Selects values with mouse/keyboard input. Single dropdowns commit with Enter; multiple choices can emit intermediate trusted input/change events. Disabled/hidden choices are rejected and the final selection is verified. |
browser_press_key(tabId, key, includeSnapshot?) | Named key (Enter, Tab, Escape, ArrowDown, …) via trusted keydown/keyup. |
browser_scroll(tabId, deltaX, deltaY, at?, method?) | Native wheel input at at: {uid}, at: {selector}, or at: {x, y}; defaults to the viewport center. Reports observed scroll positions and whether they changed. method: "javascript" explicitly requests programmatic scrolling instead. |
browser_scroll_into_view(tabId, uid?, selector?, method?, maxSteps?, includeSnapshot?) | Provide uid or selector. Uses native wheel input and verifies a visible, unobscured point. maxSteps limits wheel inputs (default 30). method: "javascript" explicitly requests programmatic scrolling instead. |
browser_wait_for(tabId, expression, timeoutMs, intervalMs?) | Poll a JS expression until truthy or timeout. |
Text replacement, pointer movement, and selection follow browser input behavior rather than assigning values or dispatching synthetic DOM (Document Object Model) events. Tool and viewer input are serialized per tab so compound gestures do not interleave. Drags use an explicit origin rather than relying on pointer position left by earlier input. Native option clicks use keyboard selection when pointer geometry is unavailable. Trusted events alone do not guarantee acceptance by bot detection.
Element clicks, typing, selection, scrolling locations, and pointer movement first use an exposed point without scrolling. They automatically use native wheel input when the target is offscreen or clipped, including nested scroll containers. Partially covered elements can be clicked on an exposed part; sticky overlays can be avoided by repositioning the target. Briefly covered targets are retried. Coordinate targets stay literal, and drag endpoints must be visible together.
The tools do not remove overlays, force-click hidden elements, or silently
switch to JavaScript scrolling. A persistent modal, hidden control, or page
that prevents scrolling still produces an explicit error rather than clicking
the wrong element. Native option selection is the semantic fallback: a normal
left click without modifiers, double-click, or hold delay selects that exact
option using keyboard input and returns method: "keyboard" and
selectedValues, rather than a fictitious clickedAt.
When browser_scroll first reveals an offscreen location, its revealed field
reports that preparation separately from the requested scroll.
Native input temporarily activates the target tab so Chromium can deliver events to background pages, then restores the tab currently shown in the viewer. Explicit JavaScript scrolling does not activate the tab.
Dragging uses one call:
browser_move_pointer({
tabId, from: { uid: sourceUid }, to: { uid: destinationUid }, mouseButton: "left"
})
Both from and mouseButton are required for dragging; omit both for ordinary movement. Each point accepts {uid}, {selector}, or {x, y}. Another agent or the viewer can move the pointer between calls, so a previous move is not a drag origin. Coordinates are CSS (Cascading Style Sheets) pixels relative to the visible page's top-left corner, not document or screen coordinates.
Changing a native selection and displaying its popup are separate capabilities.
The bridge now opts both ordinary dropdown controls and their ::picker(select)
into appearance: base-select. This completes the existing styling workaround
so menus are visible and mouse-operable in bridge frames. Multiple and sized
listboxes remain native to preserve their keyboard behavior.
Existing bridge styles are updated in place when a tab is attached, so a
bridge-only restart also upgrades pages carrying the previous incomplete rule.
Measured on Linux headless Chrome/153.0.8010.12, 2026-10-21, through the actual
bridge and its viewer frame protocol (tests/77-native-select.mjs):
| Rendering mode | Menu in bridge frames | Menu in screenshots | Keyboard and browser_select | Mouse selection |
|---|---|---|---|---|
Native appearance: auto | No | Yes | Worked, trusted events | Failed in the fixture |
Previous bridge rule: base-select on the control only | No | Yes | Worked, trusted events | Failed in the fixture |
Current bridge rule: base-select on both the control and ::picker(select) | Yes | Yes | Worked, trusted events | Worked, trusted events |
The measurement compares pixels outside the closed control in fresh bridge frames, not just the document's option values. Native and previous-bridge modes produced zero changed pixels there, while screenshots contained the popup. Their options had zero document geometry. Fully opted-in options had geometry and the menu appeared in both capture paths.
Keyboard input and value-based selection worked with initially open and closed
pickers in all three modes. Native popup mouse selection failed both through
the viewer and direct Chrome DevTools Protocol input, using option coordinates
measured from screenshots. Page.bringToFront left the popup open, so the
failure is not explained solely by the bridge's activation wrapper. The exact
native-popup mouse-routing cause remains unverified. Public
Input.synthesizeTapGesture calls with mouse and touch sources also failed to
change the native selection in this fixture; they are not a fallback.
The previous injected rule was incomplete. Chrome's customizable-select documentation requires opting in both the control and its picker. It also documents changes to sizing, placement, and operating-system integration. Applying that mode changes website rendering; this is a compatibility workaround, not native popup capture. Higher-priority author styles and controls outside the injected document styles, including shadow roots, can retain native popups. Their option actions fall back to trusted keyboard selection, but that does not repair native popup mouse routing or put those menus into screencast frames.
Screenshots and screencasts use different capture paths:
the protocol
does not expose an equivalent popup-inclusion switch on startScreencast.
Chromium's RenderWidgetHostViewAura::CopyFromSurfaceImpl calls
CopyMainAndPopupFromSurface,
whereas PageHandler::StartScreencast uses a video consumer. These source paths
explain why screenshot visibility must not be treated as screencast coverage.
Native popup capture and mouse routing remain separate limitations. The current policy completes the existing stylesheet without replacing website controls or changing the capture architecture. Passing selection tests alone does not establish native-popup rendering support; test 77 measures the actual bridge frames and input paths separately.
Human-in-the-loop:
| Tool | Purpose |
|---|---|
browser_set_attention(tabId, message) | Ask the human to act (captcha/MFA/login). Multi-paragraph message shown in a floating box on that tab. |
browser_dismiss_attention(tabId) | Clear a prior attention request. Idempotent. |
browser_list_tabs — see what's there.browser_navigate(tabId, url) if an existing tab fits. Otherwise browser_open(url).browser_get_snapshot(tabId) — agent-readable view with [uid=N] markers.browser_click({tabId, uid}) / browser_type({tabId, uid, text}) / browser_press_key({tabId, key}) — drive the page.includeSnapshot: true on an action to receive a fresh snapshot in the same response.Agents don't manage cleanup — FIFO auto-closes the oldest MCP-owned tab when you exceed MCP_MAX_OPEN_TABS (default 3).
browser_open. Counted against the FIFO cap.browser_navigate(tabId, url) works on either without affecting ownership.browser_open errors with a clear message naming the protected tab if it would be evicted.sessionStorage marker (history-hash fallback for opaque origins) that's matched back to the restored tab even though Chrome assigns new internal ids. Requires a persistent profile (CHROME_USER_DATA_DIR set) and, for a browser restart, that Chrome is configured to restore the session ("continue where you left off"); with an ephemeral profile there's nothing to restore and recovery is a no-op.When a page presents a captcha, MFA, paywall, or any human-only verification:
browser_set_attention(tabId, "Please solve the captcha so I can continue.") — multi-paragraph messages are supported.browser_wait_for(tabId, "<expr that goes truthy post-resolution>", timeoutMs) — e.g. wait for a post-captcha element.browser_dismiss_attention(tabId).The viewer pulses the tab's blinking dot and renders the message in a floating box on that tab. Concurrent attention requests are capped at MCP_MAX_ATTENTION (default 3).
Notes:
browser_list_tabs first, reuse where possible.Reverse proxy is supported out of the box, with or without a URL prefix. No configuration needed on the bridge side.
For example:
location /my-browser/ {
proxy_pass http://browser:6080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Access at https://my-nginx.com/my-browser
| Variable | Default | Description |
|---|---|---|
BRIDGE_LOG | info | Log level: error, info, debug |
BRIDGE_PORT | 6080 | Change this to change the port of the web tool. |
CDP_HOST | 127.0.0.1 | Chrome CDP host |
CDP_PORT | 18800 | Chrome remote debugging port |
SCREENCAST_QUALITY | 80 | JPEG quality (1-100) |
VIEWPORT_WIDTH | 1920 | Viewport width |
VIEWPORT_HEIGHT | 1080 | Viewport height |
CHROME_USER_DATA_DIR | (unset) | When set, Chrome persists its profile here. Mount a volume at this path to keep cookies/sessions/extensions across restarts. Unset = ephemeral /tmp/... profile. |
MCP_MAX_OPEN_TABS | 3 | Max simultaneous MCP-owned tabs (FIFO cap). |
MCP_MAX_ATTENTION | 3 | Max simultaneous attention requests. |
Content type
Image
Digest
sha256:dded508dc…
Size
443.9 MB
Last updated
about 9 hours ago
docker pull deaddev/browser