URL in. PDF out.
No API key, no lock-in.
A drop-in, self-hosted replacement for Cloudflare's
Browser Rendering /pdf endpoint.
Same request schema, so pointing your app at this instead of Cloudflare's is a one-line base-URL change.
SSRF-guarded
Private, loopback, link-local and cloud-metadata targets are blocked by default.
Drop-in compatible
Same request field names as Cloudflare's /pdf endpoint — swap the base URL, done.
Docker-ready
One Dockerfile, Chromium included, healthcheck built in — no manual setup.
No vendor lock-in
No account, no API token to manage — runs anywhere Docker runs.
Quick start
Three ways to call the API — pick the tab that matches what you're rendering.
curl -X POST http://localhost:3000/pdf \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com"}' \
--output page.pdf
curl -X POST http://localhost:3000/pdf \
-H 'Content-Type: application/json' \
-d '{"html": "<h1>Hello, world</h1>"}' \
--output hello.pdf
curl -X POST http://localhost:3000/pdf \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"pdfOptions": {
"format": "A4",
"printBackground": true,
"landscape": false,
"margin": { "top": "1cm", "bottom": "1cm" }
}
}' \
--output styled.pdf
Request parameters
The request body mirrors Cloudflare's /pdf schema field-for-field. Expand a group to see what it covers — full behavior is documented in Cloudflare's own docs, since these options pass straight through to Puppeteer.
Core exactly one required
url | Page URL to render. |
html | Raw HTML string to render instead of fetching a URL. |
Navigation & environment
gotoOptions | Navigation options: timeout, waitUntil, referer, referrerPolicy. |
viewport | width, height, deviceScaleFactor, isMobile, etc. |
userAgent | Custom User-Agent string. |
setJavaScriptEnabled | Enable/disable JS execution on the page. |
Auth & headers
cookies | Array of cookies to set before navigation. |
authenticate | HTTP basic auth: { username, password }. |
setExtraHTTPHeaders | Extra request headers as a string map. |
Content injection & filtering
addScriptTag / addStyleTag | Inject scripts/styles before rendering. |
allow/rejectResourceTypes | Allow-list or block-list resource types (e.g. image, font). |
allow/rejectRequestPattern | Allow-list or block-list request URLs by regex. |
Waiting & rendering
waitForSelector | { selector, timeout?, visible?, hidden? } — wait for an element before rendering. |
waitForTimeout | Fixed delay (ms) before rendering. |
emulateMediaType | "screen" or "print" CSS media emulation. |
bestAttempt | Still return a PDF even if navigation/wait steps fail. |
actionTimeout | Overall timeout (ms) for the whole render. |
PDF output
pdfOptions | format, width/height, landscape, scale, margin, printBackground, displayHeaderFooter, headerTemplate/footerTemplate, pageRanges, preferCSSPageSize. |
What you get back
On success: 200 OK with Content-Type: application/pdf and the PDF as the response body.
200OK — PDF rendered successfully, returned as the response body.400Bad Request — invalid request body, or the target was blocked by the SSRF guard.429Too Many Requests — over the per-IP rate limit or concurrent render limit.502Bad Gateway — the render itself failed.504Gateway Timeout — the render exceeded its timeout.Limits & notes
No API key is required by default — this is an open, self-hosted endpoint, protected by the safeguards below instead of a secret.
SSRF protection
Requests targeting private, loopback, link-local, or cloud-metadata addresses (e.g. 127.0.0.1, 10.0.0.0/8, 169.254.169.254) are blocked by default.
Rate & concurrency limits
Up to 30 requests per IP every 60s, and 4 renders in flight at once. Both return 429 once exceeded.
Render timeouts
Each render gets an overall 60s timeout — slow renders return 504 Gateway Timeout instead of hanging.
Body size cap
JSON request bodies are capped at 2MB — relevant mainly for large html payloads.
Ready to self-host it?
One Dockerfile, one docker compose up, no account required.