Minimal Chromium gateway written in Rust. It lets remote Puppeteer and Playwright clients connect to isolated Chromium instances over WebSocket, with token authentication, a concurrency limit, a FIFO queue and robust process cleanup.
docker build -t pinokio .
docker run --rm -p 3000:3000 --shm-size=1g -e TOKEN=secret pinokioThen connect:
const puppeteer = require("puppeteer-core");
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://localhost:3000?token=secret",
});src/
main.rs bootstrap, tracing, SIGTERM/SIGINT handling
config.rs env var parsing and validation (refuses to start on invalid values)
errors.rs error types mapped to HTTP status codes
server.rs Axum routes: / and /chromium (WebSocket), /health, /ready, /status
auth.rs token check (?token= or Authorization: Bearer), constant-time compare
queue.rs concurrency gate: session slots + FIFO queue
chromium.rs process launch, DevToolsActivePort discovery, SIGTERM/SIGKILL teardown
browser_archive.rs first-start download of an operator-chosen browser archive
session.rs active session lifecycle and guaranteed cleanup
proxy.rs transparent bidirectional WebSocket relay (no CDP interpretation)
Session flow:
- The client opens a WebSocket to
/(or/chromium) with its token. - Before the upgrade is answered, the server authenticates the client, waits for a free slot (or queues the request), launches an isolated Chromium and connects to its local CDP endpoint. Any failure at this stage returns a proper HTTP status code.
- The upgrade completes and the server relays frames between the client and Chromium without touching them.
- When the client disconnects, Chromium exits, a timeout fires or the server shuts down, the session is torn down: Chromium process group killed (SIGTERM then SIGKILL), temp profile dir removed, slot released, next queued request served.
- Slots are a
tokio::sync::SemaphorewithMAX_CONCURRENT_SESSIONSpermits. Tokio semaphores wake waiters in FIFO order, which provides the fair queue and makes it impossible to bypass. - Queue admission uses an atomic counter with a compare-and-swap loop: once
MAX_QUEUE_LENGTHwaiters are registered, the next request is rejected immediately with 429. With 10 slots and a queue of 20, the 31st concurrent request is refused. - A permit is owned by its session and released exactly once when the session ends, including on panic (drop guard). A client that disconnects while queued is removed from the queue automatically because its request future is dropped.
- No status messages are sent on the main endpoint before proxying: standard CDP clients (Puppeteer, Playwright) would break. Queue visibility is provided by
GET /statusinstead.
Each session launches its own process with --headless=new, --remote-debugging-port=0 and a unique temp --user-data-dir. The CDP port is read from the DevToolsActivePort file that Chromium writes in that directory; logs are never parsed. The CDP endpoint listens on 127.0.0.1 only and is never exposed.
Headless sessions are made to look like a desktop Chrome, since the differences are exactly what bot checks score: navigator.webdriver is turned off (--disable-blink-features=AutomationControlled), the emulated screen is a standard desktop resolution that fits the window instead of 800x600 (--screen-info), the window is sized so the inner viewport matches the client's viewport (plus 87px of toolbar), media queries report a fine pointer with hover, and a microphone, camera and speaker are enumerable (--use-fake-device-for-media-stream). The User-Agent is set with --user-agent so pages, dedicated, shared and service workers all report the same string: the client's userAgent launch option, or the binary's own UA without its Headless marker. What remains visible is the software WebGL renderer (SwiftShader), which has no fix short of a GPU; the image ships Mesa's EGL libraries so CHROME_EXTRA_ARGS=--use-gl=angle --use-angle=gl-egl --ignore-gpu-blocklist --enable-unsafe-swiftshader reports a Mesa llvmpipe renderer instead, at the cost of about two seconds to create the first WebGL context in a session.
Each Chromium runs in its own process group (setsid). Teardown signals only that group: SIGTERM, 3 s grace, then SIGKILL, then the child is reaped. In Docker, tini (PID 1) reaps any re-parented grandchildren. No global pkill is ever used.
| Endpoint | Description |
|---|---|
GET / or GET /chromium |
WebSocket upgrade, creates a session and proxies CDP |
GET /health |
Liveness: {"status":"ok"} |
GET /ready |
Readiness: 200 when accepting requests, 503 when shutting down or saturated |
GET /status |
Counters: active/queued sessions vs limits, plus the browser identity block (engine, name, version, sha256, path); token-protected when auth is on |
HTTP errors before the upgrade:
| Code | Meaning |
|---|---|
| 401 | Missing or invalid token |
| 429 | Queue full |
| 503 | Server shutting down or Chromium unavailable |
| 504 | Queue timeout or Chromium startup timeout |
After the upgrade, sessions are closed with WebSocket codes 1000 (Chromium closed normally), 1001 (session timeout or server shutdown) or 1011 (Chromium error).
All configuration is done through environment variables, validated at startup. Invalid values prevent the server from starting.
| Variable | Default | Description |
|---|---|---|
HOST |
0.0.0.0 |
Bind address |
PORT |
3000 |
Listen port |
TOKEN |
empty | Shared secret; empty disables authentication |
MAX_CONCURRENT_SESSIONS |
10 |
Maximum simultaneously active Chromium sessions |
MAX_QUEUE_LENGTH |
20 |
Maximum requests waiting for a slot; beyond that, 429 |
CONNECTION_TIMEOUT_MS |
600000 |
Maximum lifetime of an active session |
QUEUE_TIMEOUT_MS |
600000 |
Maximum wait in the queue |
CHROME_STARTUP_TIMEOUT_MS |
15000 |
Time Chromium gets to publish its CDP endpoint |
SHUTDOWN_GRACE_PERIOD_MS |
10000 |
Time given to active sessions after SIGTERM/SIGINT |
BROWSER_ARCHIVE_URL |
empty | .tar.gz or .zip containing a chrome executable, downloaded into /opt/browsers on first start and launched instead of the bundled Chromium |
BROWSER_ARCHIVE_SHA256 |
empty | Optional hex SHA-256 the archive must match; the actual hash is logged either way |
CHROME_PATH |
auto | Browser binary. Unset: /opt/browser/chrome if present, else the downloaded archive, else /usr/bin/chromium |
CHROME_HEADLESS |
true |
Run with --headless=new |
CHROME_NO_SANDBOX |
true |
Add --no-sandbox (see security notes) |
CHROME_DISABLE_DEV_SHM_USAGE |
true |
Add --disable-dev-shm-usage |
CHROME_EXTRA_ARGS |
empty | Extra Chromium args, whitespace-separated |
LOG_LEVEL |
info |
trace, debug, info, warn, error |
TZ |
system | Default time zone of every session. Unset, a zone is derived from the session language (fr-FR gives Europe/Paris) so containers do not report UTC |
LANGUAGE |
system | Default browser language (fr-FR or fr-FR:fr), passed to Chromium as --accept-lang and as its own LANGUAGE environment |
Clients cannot inject arbitrary Chromium launch arguments. The only per-session knobs are the JSON launch query parameter fields proxyServer, proxyBypassList, disableWebSecurity, acceptLanguage (comma-separated BCP 47 tags, overrides LANGUAGE), userAgent (printable ASCII, at most 512 characters, applied with --user-agent), viewport ({"width","height"}, drives --window-size and --screen-info) and timezone (IANA name such as Europe/Paris, passed as TZ to the browser process so Date, Intl and workers agree; overrides the server's TZ); everything else is validated and mapped to fixed flags server-side. Other server-wide flags go in CHROME_EXTRA_ARGS.
On Linux, Chromium takes its application locale from the LANGUAGE environment variable and ignores --lang, so each session's primary tag is passed as LANGUAGE to the browser process. The image installs chromium-l10n next to the bundled Chromium so that locale has its pack: the default JavaScript Intl locale then matches Accept-Language (a French session resolves Intl.DateTimeFormat().resolvedOptions().locale to fr, a German one to de, not en-US), which is one of the consistency checks bot detection runs. --accept-lang does not depend on locale packs, so websites receive the requested Accept-Language regardless.
The published image bundles one browser, the Debian chromium package at /usr/bin/chromium. Pinokio does not care which Chromium-based build it launches, though: Google Chrome, Chrome for Testing, or a patched build such as CloakBrowser all work as long as the binary speaks CDP and accepts the standard flags above. Two ways to use another one, both keeping third-party binaries out of the image and under the operator's own license acceptance:
Downloaded archive. Set BROWSER_ARCHIVE_URL to a .tar.gz or .zip (format detected from the content, e.g. the Chrome for Testing chrome-linux64.zip) whose root (or single top-level directory) contains chrome. On first start Pinokio streams the archive into /opt/browsers/<url-hash-prefix>/, logs its SHA-256, extracts it and fixes permissions. Mount a volume at /opt/browsers so restarts skip the download; a new URL installs next to the previous one, re-publishing under the same URL requires clearing the volume. Optionally set BROWSER_ARCHIVE_SHA256 to the hash published by the author: Pinokio then refuses anything else. A checksum or download failure aborts the install and the process exits non-zero. While the download runs, /health answers, /ready returns 503 with browser_installing, and session requests get 503 so clients retry.
Mounted directory. Mount the whole browser directory (executable, shared libraries, resources) at /opt/browser; it takes precedence over a downloaded archive and over the bundled Chromium:
services:
pinokio:
volumes:
- /home/user/my-browser:/opt/browser:ro
environment:
# Optional vendor-specific flags
CHROME_EXTRA_ARGS: "--some-vendor-flag"Binary resolution when CHROME_PATH is unset: /opt/browser/chrome if it exists, else the downloaded archive, else /usr/bin/chromium. Set CHROME_PATH explicitly only when the executable has another name. Pinokio runs as uid 10001, so mounted files must be world-readable and the executable world-executable. Vendor flags are never added implicitly; pass them through CHROME_EXTRA_ARGS. At startup Pinokio logs a browser binary line with the engine (chromium, downloaded or custom), the path, the product name and version from --version, and the SHA-256 of the executable. The same fields are returned in the browser block of GET /status, so clients can confirm which build served them: two builds of the same Chromium release (stock vs patched) share a version string but never a hash. Compare it with sha256sum on the file. Third-party binaries keep their own license terms.
const puppeteer = require("puppeteer-core");
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://localhost:3000?token=secret",
});
try {
const page = await browser.newPage();
await page.goto("https://example.com");
console.log(await page.title());
} finally {
await browser.close();
}const { chromium } = require("playwright");
const browser = await chromium.connectOverCDP("ws://localhost:3000?token=secret");
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("https://example.com");
console.log(await page.title());
} finally {
await browser.close();
}Compatibility notes:
- Pinokio exposes a raw CDP WebSocket endpoint, not a Playwright-native (
connect()) endpoint. Playwright must useconnectOverCDP, which is Chromium-only and skips some Playwright-managed niceties (it attaches to the existing browser instead of controlling launch). - The
http://form ofconnectOverCDPis not supported: it relies on aGET /json/versiondiscovery request, which would require creating the session outside the WebSocket handshake. Use thews://form, verified to work. wss://is handled by your reverse proxy (Traefik, Nginx, Caddy, HAProxy); Pinokio itself does not terminate TLS.
The provided Dockerfile builds a multi-stage image: Rust builder, then a Debian slim runtime with Chromium, running as a non-root user with tini as PID 1 and a healthcheck on /health.
Example compose service:
services:
pinokio:
build: ./pinokio
restart: unless-stopped
ports:
- "3000:3000"
shm_size: "1gb"
environment:
TOKEN: "${PINOKIO_TOKEN:-}"
MAX_CONCURRENT_SESSIONS: 10
MAX_QUEUE_LENGTH: 20
CONNECTION_TIMEOUT_MS: 600000
TZ: "Europe/Paris"
LANGUAGE: "fr-FR"shm_size matters: Chromium uses /dev/shm for rendering buffers and the Docker default of 64 MB makes tabs crash under load. Give it 1 GB, or keep CHROME_DISABLE_DEV_SHM_USAGE=true (then Chromium falls back to /tmp, slightly slower but safe).
- The Chromium CDP port listens on 127.0.0.1 inside the container and is never exposed; only the authenticated proxy reaches it.
- Each session gets a unique temp profile directory, removed at teardown.
- Clients cannot inject launch arguments or execute anything server-side; the server never interprets CDP payloads.
- WebSocket frames are capped at 16 MiB (64 MiB per message) in both directions.
--no-sandboxdisables Chromium's internal sandbox. It is required in most containers (no user namespaces). Mitigations: the container runs as a non-root user, one Chromium per session, and pages you drive are the main threat, so avoid pointing sessions at untrusted content with sensitive credentials loaded. If your kernel allows it, setCHROME_NO_SANDBOX=false.- Tokens are never logged; neither are CDP payloads or page contents.
- One WebSocket connection = one Chromium instance. There is no session reuse, prebooting or
browserWSEndpointreconnection to a running session. - Playwright only via
connectOverCDPwith aws://URL (no/json/versiondiscovery, no Playwright-native protocol). - Metrics are limited to the
/statuscounters.
Proprietary. Use is permitted only as part of the Puppetflow product; see LICENSE. Third-party dependencies (Rust crates, Chromium) keep their own licenses.
