Stateless HTTP service that renders a URL to PDF or PNG screenshot using
headless Chrome via Playwright. Runs a fixed
pool of BROWSER_POOL_SIZE Chrome processes, launched eagerly at startup
and routed by least-busy (never idle-evicted — only a genuinely
disconnected browser process is torn down and relaunched).
POST /pdfandPOST /screenshot— render anyhttp(s)URL, get raw bytes back synchronously.- Window size (
DEFAULT_WINDOW_WIDTH/DEFAULT_WINDOW_HEIGHT, the Chrome process's own launch dimensions) and viewport (DEFAULT_VIEWPORT_WIDTH/DEFAULT_VIEWPORT_HEIGHT, the per-request page content area) are separate, independently env-configurable concepts. A request'swidth/heightoverride the viewport only, withinMIN/MAX_WIDTH/HEIGHT. full_page(screenshot) andauto_height(screenshot or pdf) capture content beyond the requested viewport height — see API below.format(pdf only) passes a named paper size (e.g."A4") straight through to Playwright.- A fixed pool of
BROWSER_POOL_SIZEChrome processes is launched eagerly at startup and never idle-evicted; requests route to whichever pooled instance currently has the fewest in-flight renders. - Per-instance concurrency is capped (
MAX_PAGES_PER_BROWSER); requests beyond the cap queue instead of spawning more browser processes. - Render timeout is configurable server-wide (
RENDER_TIMEOUT_SECONDS) and overridable per request (timeout_ms). - Ships as a single self-contained container image — Chromium and all its OS dependencies are baked in, no external runtime dependency.
No auth, no rate limiting, no persistent storage, no multi-node
coordination, no custom render options (PDF headers/footers, PDF margins).
See
docs/superpowers/specs/2026-08-08-browser-farm-design.md
for the full design rationale.
docker build -t browser-farm .
docker run --rm -p 8080:8080 \
--security-opt seccomp=./seccomp_profile.json \
--security-opt no-new-privileges \
browser-farmOne of the sandbox-enabling flags above is required — the container
enables Chromium's real sandbox and fails fast at startup without it; see
docs/docker.md for why, and
for the --cap-add=SYS_ADMIN fallback if mounting a custom seccomp
profile isn't possible in your environment.
curl -s -o out.pdf -w '%{http_code}\n' -X POST http://localhost:8080/pdf \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}'
curl -s -o out.png -w '%{http_code}\n' -X POST http://localhost:8080/screenshot \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}'See docs/docker.md for full container documentation
(build, run, configuration, health/troubleshooting notes).
Full request/response reference:
docs/openapi.yaml (OpenAPI 3.0). Quick summary:
| Endpoint | Body | Response |
|---|---|---|
POST /pdf |
{"url", "width"?, "height"?, "timeout_ms"?, "wait_for_event"?, "auto_height"?, "format"?} |
application/pdf bytes |
POST /screenshot |
{"url", "width"?, "height"?, "timeout_ms"?, "wait_for_event"?, "full_page"?, "auto_height"?} |
image/png bytes |
url must be an absolute http/https URL. width/height set the
per-request viewport (independent of the server's window size — see
Configuration); omitted, they default to DEFAULT_VIEWPORT_WIDTH/
DEFAULT_VIEWPORT_HEIGHT, and if present must fall within
MIN/MAX_WIDTH/HEIGHT or the request is rejected (400).
timeout_ms overrides RENDER_TIMEOUT_SECONDS for that request only.
wait_for_event names a window event the target page is expected to
dispatch (window.dispatchEvent(new Event("your-event-name"))) once its
content is ready to capture; browser-farm waits for it (bounded by the
same timeout budget as the rest of the request) after navigation and
before capture. Omit it for the default behavior (capture as soon as
Playwright's networkidle state is reached). If the event never fires
within the timeout, the request fails the same way an ordinary
navigation timeout does (504). Note: the event wait is additive — the
page must still reach Playwright's networkidle state first. A page
holding a persistent connection (websocket, long-poll, polling fetch)
will time out at that stage regardless of whether the event fired.
full_page (screenshot only, 400 on /pdf): Playwright's native
full-scrollable-page capture, ignoring the requested/default viewport
height. auto_height (screenshot or pdf): measures the target page's
actual content height (document.documentElement.scrollHeight) and
uses that instead of the requested/default height — for /screenshot
this resizes the viewport to match; for /pdf this sets an explicit
page width/height sized to the content (Playwright has no native
full-page concept for PDF). height is accepted but unused when either
full_page or auto_height is true. full_page and auto_height
together is 400. auto_height and format together on /pdf is
400 (an explicit content-sized page and a named paper format are
mutually exclusive).
format (pdf only, 400 on /screenshot): a named paper format (e.g.
"A4", "Letter", "Legal") passed straight through to Playwright's
own PagePdfOptions.Format — Playwright's accepted values are the
source of truth, not re-validated here. Omitted (and no auto_height),
Playwright's own default (Letter) is used.
Status codes: 200 success, 400 invalid request, 503 pool saturated
(timed out waiting for a free page slot), 504 render timed out, 500
other failure.
All configuration is via environment variables:
| Var | Default | Meaning |
|---|---|---|
BROWSER_POOL_SIZE |
1 |
Number of Chrome processes kept warm for the process lifetime. |
DEFAULT_WINDOW_WIDTH |
3840 |
Chrome's own launch --window-size width. Pool-wide, launch-time only — not overridable per request. |
DEFAULT_WINDOW_HEIGHT |
2160 |
Chrome's own launch --window-size height. Same constraints as above. |
DEFAULT_VIEWPORT_WIDTH |
1920 |
Per-request page viewport width used when a request omits width. |
DEFAULT_VIEWPORT_HEIGHT |
1080 |
Per-request page viewport height used when a request omits height (ignored if full_page or auto_height is true). |
MIN_WIDTH / MAX_WIDTH |
200 / 3840 |
Accepted range for a request's width. |
MIN_HEIGHT / MAX_HEIGHT |
200 / 3840 |
Accepted range for a request's height. |
MAX_PAGES_PER_BROWSER |
5 |
Concurrent pages per Chrome instance before requests queue. |
RENDER_TIMEOUT_SECONDS |
60 |
Default max time for navigation + capture. Overridable per-request via timeout_ms. |
PORT |
8080 |
HTTP listen port. |
OTEL_ENABLED |
false |
Enable OpenTelemetry tracing. When true, a real OTLP/HTTP exporter is configured using the OTel SDK's own standard env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME, etc. — not listed here, see the OpenTelemetry SDK environment variable spec). When false (default), tracing is fully inert — no exporter, no network calls, negligible overhead. |
ALLOWED_URL_HOSTS |
(empty) | Comma-separated wildcard host patterns (e.g. *.example.com,api.internal.corp). A leading *. matches any subdomain and the bare domain. Empty means no allowlist restriction (subject to BLOCK_PRIVATE_IPS below). An explicit match always bypasses the private-IP block. Known limitation: only the original caller-supplied URL is checked against the allowlist — an open redirect on an allowlisted host to a different, non-allowlisted (or internal) target is not revalidated (see the BLOCK_PRIVATE_IPS row). |
BLOCK_PRIVATE_IPS |
true |
Reject requests whose target host resolves to a private, loopback, link-local, or unspecified (0.0.0.0/::) address (covers RFC1918, 127.0.0.0/8, 169.254.0.0/16 — including the cloud metadata endpoint — and the any-address/localhost-filter-bypass case). Known limitations: (1) this check resolves DNS at request-validation time, not at the moment the browser actually connects, so it does not fully close a DNS-rebinding attack; (2) only the original caller-supplied URL is validated — Chromium follows HTTP redirects internally with no revalidation, so a redirect (including an open redirect on an allowlisted host) to an internal/private address is not currently blocked. Closing this would require redirect-time interception (e.g. Playwright request routing) or an egress proxy — out of scope for this pass. |
Breaking change: WINDOW_SIZES and IDLE_TIMEOUT_SECONDS (from earlier
versions) have been removed. config.Load() silently ignores unrecognized
env vars, so if either is still set in an existing deployment, startup
will succeed with no warning and silently different behavior. Use
BROWSER_POOL_SIZE and DEFAULT_VIEWPORT_WIDTH/DEFAULT_VIEWPORT_HEIGHT
instead, and note that pooled instances are no longer idle-evicted — see
the pool description above.
Set OTEL_ENABLED=true to emit one OpenTelemetry span per /pdf or
/screenshot request (attributes: render.kind, render.width,
render.height — the resolved per-request viewport, not window size;
errors are recorded on the span with status Error).
Configure where spans go using the OTel SDK's standard environment
variables — most commonly just OTEL_EXPORTER_OTLP_ENDPOINT:
docker run --rm -p 8080:8080 \
--security-opt seccomp=./seccomp_profile.json \
--security-opt no-new-privileges \
-e OTEL_ENABLED=true \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
browser-farmWhen OTEL_ENABLED is unset or false, no exporter is configured and no
tracing-related network calls are made.
For a ready-to-run local stack (Tempo + Prometheus + Grafana, fully
provisioned) to see tracing in action, see
docs/docker.md.
Requires Go 1.26+.
go build ./...
go vet ./...
go test ./... # unit tests, no browser required
go test ./... -raceThe Playwright-backed browser launcher has an opt-in integration test that
launches real Chromium (requires it installed locally, see
docs/docker.md for the install
command):
go test -tags=integration ./internal/browserpool/... -run TestPlaywrightLauncher -vinternal/config/ env-driven configuration (Size, Config, Load)
internal/browserpool/ Chrome instance pool: launch/reuse/idle-evict,
per-instance concurrency limiting, real Playwright
launcher
internal/httpapi/ HTTP handlers for /pdf and /screenshot
main.go wiring: config -> launcher -> pool -> server
Dockerfile self-contained container build
docs/openapi.yaml API specification
docs/docker.md container build/run/configuration reference
docs/superpowers/ design spec and implementation plan for this project