{"id":88,"date":"2025-11-08T16:12:23","date_gmt":"2025-11-08T10:42:23","guid":{"rendered":"https:\/\/peedief.com\/blog\/?p=88"},"modified":"2026-04-18T00:32:04","modified_gmt":"2026-04-17T19:02:04","slug":"attach-puppeteer-to-external-chrome-using-browserd","status":"publish","type":"post","link":"https:\/\/peedief.com\/blog\/2025\/11\/08\/attach-puppeteer-to-external-chrome-using-browserd\/","title":{"rendered":"Attach Puppeteer to External Chrome Using browserd"},"content":{"rendered":"\n<p>If you\u2019ve ever tried to launch Chromium directly from Puppeteer, you know the pain \u2014 high CPU, zombie processes, and broken sandboxes.<br>Instead of spawning Chrome from every Node process, you can <strong>run it once<\/strong> as a container and <strong>connect remotely<\/strong>.<\/p>\n\n\n\n<p>That\u2019s exactly what <a href=\"https:\/\/github.com\/peedief\/browserd\"><code>browserd<\/code><\/a> does \u2014 it wraps headless Chromium with a small Go proxy that exposes a <strong>stable WebSocket<\/strong> at <code>ws:\/\/0.0.0.0:9223<\/code>.<br>Your Puppeteer client can attach directly to this endpoint \u2014 <strong>no extra HTTP fetch<\/strong>, <strong>no random <code>\/devtools\/browser\/&lt;id&gt;<\/code><\/strong>, and <strong>no lifecycle headaches<\/strong>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\uddf1 What is <code>browserd<\/code>?<\/h2>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Headless Chromium packaged with a Go proxy that gives you a fixed WebSocket endpoint (<code>ws:\/\/0.0.0.0:9223<\/code>) so Puppeteer or any CDP client can connect immediately \u2014 even across load balancers.<\/p>\n<\/blockquote>\n\n\n\n<p>The proxy inside <code>browserd<\/code> tracks Chromium\u2019s internal DevTools socket and exposes it directly, meaning:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>You always connect to <code>ws:\/\/host:9223<\/code><\/li>\n\n\n\n<li>You never need to query <code>\/json\/version<\/code> or guess the internal DevTools path<\/li>\n\n\n\n<li>You can safely scale multiple containers behind a load balancer<\/li>\n<\/ul>\n\n\n\n<p>If you don\u2019t want to run containers at all, Peedief\u2019s <a href=\"https:\/\/peedief.com\/?utm_source=blog&amp;utm_medium=internal_link&amp;utm_campaign=attach-puppeteer-to-external-chrome-using-browserd\">managed renderer<\/a> hosts the same stack for you \u2014 same automation, zero ops.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83d\ude80 Quick Start<\/h2>\n\n\n\n<p>Download the seccomp profile (for a safer sandbox) and run the container:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Download the Chromium seccomp profile\ncurl -o chromium.json https:\/\/raw.githubusercontent.com\/peedief\/browserd\/main\/chromium.json\n\n# Run browserd container\ndocker run --rm \\\n  --security-opt seccomp=chromium.json \\\n  -p 9223:9223 \\\n  --name browserd \\\n  ghcr.io\/peedief\/browserd:v1.0.0\n<\/code><\/pre>\n\n\n\n<p>That\u2019s it \u2014 the WebSocket is live at:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ws:\/\/localhost:9223\n<\/code><\/pre>\n\n\n\n<p>The Go proxy automatically connects to the internal Chrome DevTools backend, so you don\u2019t have to worry about <code>\/devtools\/browser\/&lt;id&gt;<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\udde9 Connect Puppeteer to browserd<\/h2>\n\n\n\n<p>In your Node app, install Puppeteer Core (no bundled Chrome):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm install puppeteer-core\n<\/code><\/pre>\n\n\n\n<p>Then connect directly to the proxy:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ connect.js\nimport puppeteer from 'puppeteer-core';\n\nasync function main() {\n  const browser = await puppeteer.connect({\n    browserWSEndpoint: 'ws:\/\/localhost:9223',\n  });\n\n  try {\n    const page = await browser.newPage();\n    await page.goto('https:\/\/example.com');\n    console.log(await page.title());\n    await page.close();\n  } finally {\n    browser.disconnect(); \/\/ don't call browser.close()\n  }\n}\n\nmain().catch((err) =&gt; {\n  console.error(err);\n  process.exit(1);\n});\n<\/code><\/pre>\n\n\n\n<p>That\u2019s all it takes.<br>No <code>\/json\/version<\/code> calls, no internal WebSocket discovery \u2014 just a <strong>single stable endpoint<\/strong>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\uddf0 Using Docker Compose<\/h2>\n\n\n\n<p>For a cleaner setup, define it in <code>docker-compose.yml<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  browserd:\n    image: ghcr.io\/peedief\/browserd:v1.0.0\n    ports:\n      - \"9223:9223\"\n    security_opt:\n      - seccomp=chromium.json<\/code><\/pre>\n\n\n\n<p>Then download the seccomp file and bring it up:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -o chromium.json https:\/\/raw.githubusercontent.com\/peedief\/browserd\/main\/chromium.json\ndocker compose up -d browserd<\/code><\/pre>\n\n\n\n<p>You now have a fully sandboxed headless Chromium instance that any remote Puppeteer client can connect to at <code>ws:\/\/localhost:9223<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\udde0 Why This Is Better<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Old Way (Direct Launch)<\/th><th>With <code>browserd<\/code><\/th><\/tr><\/thead><tbody><tr><td>Every Node process spawns its own Chromium instance<\/td><td>One centralized container hosts Chromium for all clients<\/td><\/tr><tr><td>WebSocket URL changes every run (<code>\/devtools\/browser\/&lt;id&gt;<\/code>)<\/td><td>Stable <code>ws:\/\/host:9223<\/code> endpoint \u2014 never changes<\/td><\/tr><tr><td>You must fetch <code>\/json\/version<\/code> before connecting<\/td><td>No discovery step \u2014 connect instantly<\/td><\/tr><tr><td>High CPU, memory leaks, zombie Chrome processes<\/td><td>One managed Chrome lifecycle handled by the proxy<\/td><\/tr><tr><td>Sandboxing disabled with <code>--no-sandbox<\/code> for simplicity<\/td><td>Runs under real <strong>seccomp sandbox<\/strong> (<code>chromium.json<\/code>)<\/td><\/tr><tr><td>Hard to scale horizontally<\/td><td><strong>Easily load-balance<\/strong> multiple <code>browserd<\/code> containers \u2014 all expose the same consistent WebSocket path<\/td><\/tr><tr><td>Each app handles crashes separately<\/td><td>Browser lifecycle isolated inside the container<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p><code>browserd<\/code> isn\u2019t just cleaner \u2014 it\u2019s <em>scalable by design<\/em>.<br>Spin up 3\u20134 replicas behind NGINX, Traefik, or any load balancer, and your Puppeteer clients can connect to any of them using the exact same <code>ws:\/\/host:9223<\/code> path.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\uddf1 Health &amp; Scaling<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A <code>\/healthz<\/code> endpoint is exposed for readiness\/liveness checks.<\/li>\n\n\n\n<li>You can run multiple browserd instances and load-balance them \u2014 since every proxy exposes the same stable <code>ws:\/\/<\/code> path.<\/li>\n\n\n\n<li>Each proxy holds one persistent Chromium process internally, managing its DevTools lifecycle for you.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\u2699\ufe0f Production Tips<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Use the seccomp profile<\/strong> (<code>chromium.json<\/code>) \u2014 don\u2019t disable the sandbox unless you absolutely have to.<\/li>\n\n\n\n<li><strong>Limit resources<\/strong>: \n<ul class=\"wp-block-list\">\n<li><code>deploy:<br>&nbsp;&nbsp;resources:<br>&nbsp;&nbsp;&nbsp;&nbsp;limits:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;cpus: \"2.0\"<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;memory: 2g<\/code><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li><strong>Add auth or IP whitelisting<\/strong> if exposing beyond localhost.<\/li>\n\n\n\n<li><strong>Monitor <code>\/healthz<\/code><\/strong> for restarts or resource exhaustion.<\/li>\n\n\n\n<li><strong>Scale horizontally<\/strong> \u2014 each <code>browserd<\/code> instance can handle its own browser process.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\ud83e\uddfe Summary<\/h2>\n\n\n\n<p>\u2705 Run <code>browserd<\/code> once \u2192 exposes <code>ws:\/\/localhost:9223<\/code><br>\u2705 Connect Puppeteer directly using that endpoint<br>\u2705 No <code>\/json\/version<\/code> fetches or dynamic IDs<br>\u2705 Sandbox stays intact under seccomp profile<br>\u2705 Load-balance multiple containers effortlessly<br>\u2705 Shared, stable, and production-ready Chrome layer<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong>In short:<\/strong><br><code>browserd<\/code> turns headless Chrome into a <strong>simple, stable, load-balanced microservice<\/strong> you can attach to instantly from Puppeteer or any CDP client.<\/p>\n\n\n\n<p>If you ever got tired of fighting Chrome flags, zombie processes, or unstable endpoints \u2014 this container is your new friend.<br>And if you want to skip containers altogether, <a href=\"https:\/\/peedief.com\/?utm_source=blog&amp;utm_medium=internal_link&amp;utm_campaign=attach-puppeteer-to-external-chrome-using-browserd\">Peedief.com<\/a> hosts the same renderer stack for you &#8211; no ops, just a clean API.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>If you\u2019ve ever tried to launch Chromium directly from Puppeteer, you know the pain \u2014 high CPU, zombie processes, and broken sandboxes.Instead of spawning Chrome from every Node process, you can run it once as a container and connect remotely. That\u2019s exactly what browserd does \u2014 it wraps headless Chromium with a small Go proxy [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-88","post","type-post","status-publish","format-standard","hentry","category-guides"],"_links":{"self":[{"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/posts\/88","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/comments?post=88"}],"version-history":[{"count":5,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/posts\/88\/revisions"}],"predecessor-version":[{"id":98,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/posts\/88\/revisions\/98"}],"wp:attachment":[{"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/media?parent=88"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/categories?post=88"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/peedief.com\/blog\/wp-json\/wp\/v2\/tags?post=88"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}