Two ways to run an open source AI browser locally: pull the combined ghcr.io/steel-dev/steel-browser image and have everything – API, UI, Chromium – in one container, or use the split steel-browser-api + steel-browser-ui compose file and run them as separate services. The combined image wins for almost everyone. Fewer moving parts, one port to expose, UI auto-mounted at /ui. The split setup only makes sense if you’re putting the UI behind a different domain or scaling API replicas independently – and choosing the wrong one costs you a port binding and an hour of confusion.
This tutorial covers Steel Browser v0.5.1-beta, published November 19, 2025 (GitHub releases page). Apache 2.0 licensed, 6.2k stars as of that date, headless browser API built for AI agents – Puppeteer/Playwright on the inside, REST + WebSocket on the outside. An independent review nailed the pitch in one line: Humans use Chrome, Agents use Steel.
Be honest about RAM first
RAM is the real ceiling here – not CPU. Each active browser session consumes ~300-500 MB depending on page complexity, per Steel’s Railway template documentation. A 4 GB VM hits its limit around 8 concurrent sessions before swap kicks in. Plan accordingly before you pick a host.
| Tier | CPU | RAM | Disk | Realistic concurrency |
|---|---|---|---|---|
| Minimum (1 session) | 2 vCPU | 2 GB | 10 GB | 1-2 sessions |
| Recommended (dev) | 4 vCPU | 4 GB | 20 GB | 6-8 sessions |
| Small production | 8 vCPU | 8 GB | 40 GB | ~15 sessions |
The container ships with Chrome 128.0.6613.119 and Node 22.13.0 baked in (as of v0.5.1-beta per Steel’s official docs), so you don’t install those yourself unless you’re going bare-metal. Linux x86_64 is the smoothest host – Apple Silicon has a known wrinkle covered below.
Pull the image
docker pull ghcr.io/steel-dev/steel-browser:latest
For a repeatable deployment, pin to the v0.5.1-beta tag instead of :latest. The project is in public beta – independent reviewers have flagged occasional breaking changes between minor versions. Lock it down if stability matters.
Run it
One command, persistent log storage included:
docker run -d
--name steel-browser
-p 3000:3000
-p 9223:9223
-v steel-data:/data
-e LOG_STORAGE_ENABLED=true
-e LOG_STORAGE_PATH=/data/browser-logs.duckdb
ghcr.io/steel-dev/steel-browser:latest
Port 3000 serves the REST API and the UI at http://localhost:3000/ui. Port 9223 is the Chrome DevTools Protocol socket. Steel’s docs carry an explicit warning: never expose 9223 to the public internet. Anyone who reaches it gets full CDP control of your Chrome instance – the Railway template intentionally keeps it private-network-only for exactly this reason.
Split services with Docker Compose
UI on its own port, API separate:
services:
api:
image: ghcr.io/steel-dev/steel-browser-api:latest
ports:
- "3000:3000"
- "9223:9223"
environment:
- DOMAIN=localhost:3000
- CDP_DOMAIN=localhost:9223
- LOG_STORAGE_ENABLED=true
- LOG_STORAGE_PATH=/data/logs/browser-logs.duckdb
volumes:
- logs:/data/logs
ui:
image: ghcr.io/steel-dev/steel-browser-ui:latest
ports:
- "5173:80"
environment:
- API_URL=http://api:3000
volumes:
logs:
docker compose up -d. UI on 5173, API on 3000.
Configuration variables worth knowing
Self-hosted Steel has no API key and no auth by default – you’re the gatekeeper. The env vars you’ll actually touch:
DOMAIN– the public hostname the API reports back in session URLs. Running on a VPS? Set this to your real domain so live viewer links resolve correctly.CDP_DOMAIN– same idea, for the WebSocket endpoint Puppeteer/Playwright connect to.LOG_STORAGE_ENABLED=true– activates the DuckDB log store. DuckDB is embedded and zero-dependency, which is why Steel chose it over Postgres for session logs. Without this flag, browser logs vanish when the container restarts.CHROME_EXECUTABLE_PATH– bare-metal only, when Chrome isn’t in a standard lookup path.
For production: reverse proxy (Caddy or nginx) in front of port 3000, auth at that layer. Steel itself trusts whoever can reach the port.
Verify the install
Three checks, in order:
- Health endpoint:
curl http://localhost:3000/v1/health→ should return{"status":"ok"}. - Swagger UI:
http://localhost:3000/documentation– full REST reference, interactive. - Create a session:
curl -X POST http://localhost:3000/v1/sessions -H "Content-Type: application/json" -d '{"blockAds": true, "dimensions": {"width": 1280, "height": 800}}'Response includes a session ID, a
websocketUrl, and asessionViewerUrlyou can open in your browser to watch the agent live.
Connect Puppeteer in two lines:
import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({ browserWSEndpoint: "ws://localhost:3000" });
Three install traps
1. Apple Silicon: nginx io_setup failure.GitHub issue #106 captures this exactly: nginx: [emerg] io_setup() failed (38: Function not implemented). The API container reports Chrome starts fine, but CDP websocket connections die immediately. Why? The io_setup syscall is part of Linux’s async I/O interface – macOS’s kernel doesn’t expose it, and QEMU emulation (which Docker Desktop uses on M1) doesn’t implement it either. Forcing the amd64 image sidesteps the emulation layer entirely:
docker build --platform linux/amd64 .
# or in compose: add platform: linux/amd64 under each service
2. Port 3000 already in use. Node devs love that port. Kill what’s there (lsof -i :3000) or remap with -p 3001:3000. If you remap, the UI inside the container still expects to reach the API on internal port 3000 – set API_URL accordingly in the split-compose setup or the session viewer links will break.
3. “Chrome executable not found” on bare-metal. Only hits people who skipped Docker. Steel checks standard paths; if yours isn’t one of them, set CHROME_EXECUTABLE_PATH=/path/to/chrome before running npm run dev.
Stealth caveat: Steel handles standard bot checks, but independent testing confirms Cloudflare Turnstile and PerimeterX still fingerprint it. Built-in anti-detection isn’t a silver bullet. For those targets, pair Steel with a residential proxy via the
proxyUrlsession option and budget for retries.
Which raises a fair question for anyone building multi-tenant agent infrastructure: how does Steel’s session isolation actually hold under load? The docs describe per-session Chrome profiles, but what happens when 15 sessions hit the same host simultaneously and one crashes – does the container recover gracefully or do you lose the other 14? That behavior isn’t documented, and it’s worth testing before you commit to a production architecture.
Upgrade and uninstall
Upgrades are boring, which is the point:
docker pull ghcr.io/steel-dev/steel-browser:latest
docker stop steel-browser && docker rm steel-browser
# re-run your original docker run command
The steel-data volume persists logs and Chrome profile cache across restarts. Check the Steel docs for release notes before bumping – minor versions have shipped breaking API changes before.
Full uninstall:
docker stop steel-browser
docker rm steel-browser
docker volume rm steel-data
docker rmi ghcr.io/steel-dev/steel-browser:latest
Split compose users: docker compose down -v nukes containers and named volumes in one shot.
FAQ
Is Steel Browser actually free if I self-host?
Yes. Apache 2.0 – no license fees, no per-session charge, no usage meter. You pay for the box it runs on, nothing else. (The hosted version at steel.dev offers 100 hours/month free and paid plans starting at $29/month as of late 2025, but that’s a separate product.)
Can I use Steel with Playwright or Selenium instead of Puppeteer?
All three work. Create a session via POST /v1/sessions, grab the websocketUrl from the response, then connect: Playwright uses browser.connect_over_cdp(), Puppeteer uses puppeteer.connect(). Selenium is the odd one out – Steel exposes a drop-in WebDriver endpoint at /selenium, but that path skips CDP-only features like the live session viewer. If your team is already on Selenium, it works; just know you’re missing the debug tooling that makes Steel worth running in the first place.
Why not just run Puppeteer in Docker myself?
For a single-purpose scraper, lighter is better – raw Puppeteer-in-Docker is the right call. Steel’s overhead pays off the moment you need persistent sessions across separate API calls, a live debug viewer, proxy rotation, CAPTCHA hooks, or multi-tenant session isolation. That’s weeks of plumbing. The Steel image gives you a REST API for all of it on day one. If you don’t need those features yet, wait until you do – then switch.
Start with docs.steel.dev, grab the Steel CLI (curl -fsS https://setup.steel.dev | sh), and wire your first agent to the local instance. One session, live viewer open, watch it run. Then scale.