┌────────────────────────────────────────────────────────────────────────────────────────┐ │ HOST MACHINE (has Chrome) │ │ │ │ Chrome + Extension ◄────── local WS ──────► Relay Server :19988 │ │ ▲ │ │ │ local │ │ ▼ │ │ Traforo Client │ │ │ │ └────────────────────────────────────────────────────┼───────────────────────────────────┘ │ outbound WS ▼ ┌─────────────────┐ │ Cloudflare │ │ Durable Object │ │ │ │ https://{id}- │ │ tunnel. │ │ traforo.dev │ └────────┬────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────────────────────────┐ │ REMOTE MACHINE (CLI or MCP) │ │ │ │ playwriter -s 1 -e "await page.goto('https://...')" │ │ │ │ PLAYWRITER_HOST=https://{id}-tunnel.traforo.dev │ │ PLAYWRITER_TOKEN=<secret> │ └────────────────────────────────────────────────────────────────────────────────────────┘
1npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN
playwriter serve on port 19988 with token auth, and creates a traforo tunnel at https://my-machine-tunnel.traforo.dev. Keep this terminal running, or use tmux for persistent operation:123tmux new-session -d -s playwriter-remote tmux send-keys -t playwriter-remote \ "npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN" Enter
-t flag sets the tunnel ID, which becomes the URL subdomain. If omitted, a random UUID is generated. Tunnel IDs are not reserved; if someone else connects with the same ID, they replace your connection.12export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
playwriter skill) documents all available APIs. Use playwriter exactly as you would locally:123playwriter session new # outputs: 1 playwriter -s 1 -e "await page.goto('https://example.com')" playwriter -s 1 -e "console.log(await snapshot({ page }))"
1playwriter --host https://my-machine-tunnel.traforo.dev --token MY_SECRET_TOKEN -s 1 -e "..."
123456789101112{ "mcpServers": { "playwriter": { "command": "npx", "args": ["-y", "playwriter@latest"], "env": { "PLAYWRITER_HOST": "https://my-machine-tunnel.traforo.dev", "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN" } } } }
12345678import { chromium } from 'playwright-core' const browser = await chromium.connectOverCDP( 'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN', ) const page = browser.contexts()[0].pages()[0] await page.goto('https://example.com') // Don't call browser.close() - it would close the user's Chrome
-t ID and the same token. From a control machine, loop over the tunnel URLs to run commands across the fleet:12345for machine in machine-a machine-b machine-c; do PLAYWRITER_HOST="https://${machine}-tunnel.traforo.dev" \ PLAYWRITER_TOKEN=shared-secret \ playwriter -s 1 -e "console.log(await page.title())" done
/extension endpoint only accepts connections from 127.0.0.1. This means playwriter serve always runs on the host, never inside the container.PLAYWRITER_HOST to reach the host relay.┌────────────────────────────────────────────────────────────────────────────────────────┐ │ HOST MACHINE │ │ │ │ Chrome + Extension ◄────── local WS ──────► playwriter serve :19988 │ └───────────────────────────────────────────────────────▲────────────────────────────────┘ │ host.docker.internal:19988 │ ┌───────────────────────────────────────────────────────┴────────────────────────────────┐ │ DOCKER CONTAINER │ │ │ │ PLAYWRITER_HOST=host.docker.internal │ │ │ │ playwriter -s 1 -e "await page.goto('https://...')" │ └────────────────────────────────────────────────────────────────────────────────────────┘
1playwriter serve --host localhost
--host localhost binds to 127.0.0.1 so no token is needed. Docker containers reach it through host.docker.internal which routes to the host's loopback interface. If you use --host 0.0.0.0 (the default), a --token is required since it exposes the server to all network interfaces.PLAYWRITER_HOST in your container:1ENV PLAYWRITER_HOST=host.docker.internal
1docker run -e PLAYWRITER_HOST=host.docker.internal myimage
12playwriter session new playwriter -s 1 -e "await page.goto('https://example.com')"
host.docker.internal| Platform | Works out of the box? | Notes |
| macOS (Docker Desktop) | Yes | Supported since Docker Desktop 18.03 |
| Windows (Docker Desktop) | Yes | Supported since Docker Desktop 18.03 |
| Linux (Docker Engine) | No | Requires --add-host or extra_hosts (see below) |
host.docker.internal is not provided automatically by Docker Engine. You must add it explicitly:1docker run --add-host=host.docker.internal:host-gateway -e PLAYWRITER_HOST=host.docker.internal myimage
1234567services: app: build: . environment: - PLAYWRITER_HOST=host.docker.internal extra_hosts: - "host.docker.internal:host-gateway"
host-gateway special value (available since Docker Engine 20.10) resolves to the host's gateway IP.playwriter serve inside the container. This won't work because the Chrome extension can only connect to the relay via localhost, and localhost inside Docker is isolated from the host. The relay must be on the same machine as Chrome.1234567891011{ "mcpServers": { "playwriter": { "command": "npx", "args": ["-y", "playwriter@latest"], "env": { "PLAYWRITER_HOST": "host.docker.internal" } } } }
--add-host=host.docker.internal:host-gateway.playwriter serve binds to 0.0.0.0, it refuses to start without a --token. Every privileged HTTP request (/cli/*, /recording/*) needs Authorization: Bearer <token> or ?token=<token>, and every /cdp WebSocket connection needs ?token=<token>. Without the correct token, the relay returns 401./extension WebSocket endpoint only accepts connections from 127.0.0.1 or ::1. A remote attacker cannot impersonate the extension even with the token.| Variable | Description |
PLAYWRITER_HOST | Remote relay URL (e.g. https://x-tunnel.traforo.dev) or IP (e.g. 192.168.1.10) |
PLAYWRITER_TOKEN | Authentication token for the relay server |
PLAYWRITER_PORT | Override relay port (default: 19988, not needed with traforo) |
openssl rand -hex 16-t in traforo to get a random tunnel ID for maximum security1234567# Host npx -y playwriter serve --token MY_SECRET_TOKEN # Remote (same LAN) export PLAYWRITER_HOST=192.168.1.10 export PLAYWRITER_TOKEN=MY_SECRET_TOKEN playwriter session new