You have an HTML invoice or report template on one side and a PDF that has to land in a mailbox or an S3 bucket on the other. Playwright is the piece that ties them together: it drives a headless Chromium, loads your page with the same engine your browser uses, and calls page.pdf(). This guide wires Playwright HTML to PDF conversion end to end, from a one-file script to a small API service running in Docker.
We cover both the Python and Node.js bindings, because teams usually arrive with one or the other already in production. Along the way you get headers and footers with page numbers, a wait strategy that doesn’t lose late JavaScript, a set_content pattern that keeps untrusted HTML away from your files and internal network, a FastAPI endpoint under systemd, and a Docker image built on the official Playwright base. If you are still deciding between engines, our wkhtmltopdf alternatives benchmark ranks eleven of them on the same three documents. Every number below was measured for this article on our own lab VMs, except two figures that come from that benchmark: the cold command-line timings and the 12-of-15 CSS print probe score.
Confirmed working on Ubuntu 26.04, Ubuntu 24.04, Debian 13 and Rocky Linux 10 (SELinux enforcing) in October 2026, with Playwright 1.64.0 for Node.js and 1.63.0 for Python.
How the pieces connect
Your script never talks to Chromium directly. The Node.js library launches a pinned build of Chrome Headless Shell itself, while the Python package starts a bundled Node.js driver process that does the same job on its behalf. Either way, every call you make (goto, set_content, pdf) travels over a pipe to a separate browser process. Two consequences follow from that design.
First, each Playwright release pins its own browser build. On our test date the npm package and the PyPI package were one minor release apart, so the Node.js project downloaded chromium_headless_shell-1248 while the Python project downloaded chromium_headless_shell-1243. Both sit side by side in ~/.cache/ms-playwright. Plan for roughly 260 MB of disk per build.
Second, Playwright starts Chromium with --no-sandbox unless you set chromiumSandbox to true. We confirmed the flag in the process list of the running service. So Playwright keeps working on Ubuntu 24.04 and 26.04, where kernel.apparmor_restrict_unprivileged_userns = 1 stops the bundled Chrome that Puppeteer downloads. It also means the browser has no OS sandbox around it, which makes the network lockdown later in this guide more important, not less.
This is what we ran on each system:
| System | Python path | Node.js from the distro | Result |
|---|---|---|---|
| Ubuntu 26.04 | uv venv, --with-deps | Node.js 22 (apt) | Both bindings render, API and Docker tested here |
| Ubuntu 24.04 | uv venv, --with-deps | Node.js 18 (apt) is too old, NodeSource LTS used | Both bindings render |
| Debian 13 | uv venv, --with-deps | Node.js 20 (apt) | Both bindings render |
| Rocky Linux 10 | uv venv, dnf deps first | Node.js 22 (AppStream) | Both bindings render, no AVC denials |
The verification steps use pdfinfo and pdftotext from poppler-utils, and the Docker section uses jq. Install both on Ubuntu or Debian:
sudo apt install -y poppler-utils jq
Rocky Linux and AlmaLinux carry the same two packages in AppStream:
sudo dnf -y install poppler-utils jq
Install Playwright for Python
We use uv for the virtual environment because it behaves the same on every distro and does not fight the system Python. Install it for your user and load its environment:
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.local/bin/env
Create a project directory with its own virtual environment, then add Playwright:
mkdir -p ~/pdf-py && cd ~/pdf-py
uv venv
uv pip install playwright
Print the Playwright version from inside the virtual environment to see which release uv resolved for you:
uv run playwright --version
This was the newest release on PyPI on our test date, so yours may be newer:
Version 1.63.0
Now fetch the browser. On Ubuntu and Debian, --with-deps also installs the shared libraries Chromium needs through apt (it asks for sudo, so expect a password prompt). --only-shell skips the full Chromium build, which a PDF job never uses:
uv run playwright install --with-deps --only-shell chromium
The download lands in your home cache, separate from any other project:
Downloading Chrome Headless Shell 153.0.8010.12 (playwright chromium-headless-shell v1243) from https://cdn.playwright.dev/builds/cft/153.0.8010.12/linux64/chrome-headless-shell-linux64.zip
Chrome Headless Shell 153.0.8010.12 (playwright chromium-headless-shell v1243) downloaded to /home/eugene/.cache/ms-playwright/chromium_headless_shell-1243
Downloading FFmpeg (playwright ffmpeg v1011) from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip
FFmpeg (playwright ffmpeg v1011) downloaded to /home/eugene/.cache/ms-playwright/ffmpeg-1011
Rocky Linux and AlmaLinux: install the libraries with dnf
--with-deps only knows apt. On Rocky Linux 10 it fails with sh: line 1: apt-get: command not found, so install the Chromium runtime libraries yourself first. All of them come from the BaseOS and AppStream repositories, so no EPEL is needed:
sudo dnf -y install nss atk at-spi2-atk cups-libs libdrm libxkbcommon libXcomposite libXdamage libXrandr libXfixes mesa-libgbm pango cairo alsa-lib
Then download the browser without the --with-deps flag:
uv run playwright install --only-shell chromium
Rocky Linux isn’t on Playwright’s list of supported operating systems, and the installer says so before it downloads anything. It falls back to the Ubuntu 24.04 browser build:
BEWARE: your OS is not officially supported by Playwright; downloading fallback build for ubuntu24.04-x64.
That fallback build ran every test in this guide once the libraries above were present, but treat it as working in practice rather than vendor supported. If you need the supported path, run the Docker image described later on the Rocky host instead.
SELinux stayed in enforcing mode for every test on Rocky, and ausearch -m avc -ts today returned <no matches> afterwards. None of the Python or Node.js steps we ran there needed a policy change.
Install Playwright for Node.js
Playwright for Node.js needs Node.js 20 or newer, and that is the one place the distros diverge. Ubuntu 26.04 ships Node.js 22, Debian 13 ships Node.js 20 and Rocky Linux 10 ships Node.js 22, so the distro package is enough on all three. Debian 13 needs one caveat: Node.js 20 reached upstream end of life on 2026-04-30, so Debian’s security team is the only source of fixes for it. Playwright still ran on it, but the NodeSource LTS repository below is the better choice there too:
sudo apt install -y nodejs npm
Rocky Linux 10 pulls the same packages from AppStream:
sudo dnf -y install nodejs npm
Ubuntu 24.04 is the exception. Its archive still carries Node.js 18, and Playwright refuses to start on it. Add the NodeSource LTS repository instead (the Node.js 24 guide for Ubuntu explains the repository in more detail):
curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install -y nodejs
With a supported Node.js in place, create the project as an ES module and add Playwright:
mkdir -p ~/pdf-node && cd ~/pdf-node
npm init -y
npm pkg set type=module
npm install playwright
Pull the headless shell. Drop --with-deps on Rocky Linux after running the dnf command above:
npx playwright install --with-deps --only-shell chromium
Check which version npm resolved, since this decides the browser build the project expects:
npx playwright --version
On our test date npm resolved the newest release, one minor version ahead of PyPI:
Version 1.64.0
Run your first Playwright HTML to PDF job from Python
The smallest useful script loads a local HTML file, waits until the network has gone quiet (networkidle, fine for static templates but with limits covered in the wait section), and prints it on A4. Create the script:
vim ~/pdf-py/render_pdf.py
Paste this content. prefer_css_page_size lets an @page rule in your stylesheet win over the format argument, and tagged plus outline produce an accessible PDF with bookmarks built from your headings:
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
src = Path(sys.argv[1]).resolve()
out = sys.argv[2]
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(src.as_uri(), wait_until="networkidle")
page.pdf(
path=out,
format="A4",
print_background=True,
prefer_css_page_size=True,
outline=True,
tagged=True,
)
browser.close()
print(f"wrote {out}")
Point it at any HTML file you have. The output below comes from our own test file, a four-page invoice with a web font, a flex header and a table that spans pages, so your page count will differ:
cd ~/pdf-py
uv run python render_pdf.py invoice.html invoice.pdf
The script prints the name of the file it wrote once the browser has closed cleanly:
wrote invoice.pdf
Inspect the result with pdfinfo from poppler-utils:
pdfinfo invoice.pdf | grep -E 'Creator|Producer|Tagged|Pages|Page size'
Tagged comes back as yes, and the page size matches the CSS:
Creator: Chromium
Producer: Skia/PDF m153
Tagged: yes
Pages: 4
Page size: 594.96 x 841.92 pts (A4)
pdffonts invoice.pdf also listed the Lobster web font as embedded and subset, so a custom @font-face survives the trip. In our cross-engine test the Chromium family passed 12 of 15 CSS print probes: flexbox, grid, CSS variables, :has(), nesting, @page margin boxes with counter(pages), repeated table headers and named landscape pages all worked. It still ignores string-set running headers, target-counter() page references and float: footnote. Those need a paged-media engine such as WeasyPrint.
Add a header, footer and page numbers from Node.js
If your template does not define its own @page margin boxes, Playwright can stamp a header and footer on every page. Create the Node.js script:
vim ~/pdf-node/render-pdf.mjs
Add the following. The pageNumber, totalPages and title classes are filled in by Chromium at print time:
import { chromium } from 'playwright';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
const [src, out] = process.argv.slice(2);
// Change this to an element your template adds after it finishes rendering.
const READY_SELECTOR = '#report-ready';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(pathToFileURL(path.resolve(src)).href);
await page.waitForSelector(READY_SELECTOR, { state: 'attached', timeout: 15000 });
await page.pdf({
path: out,
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '18mm', left: '12mm', right: '12mm' },
displayHeaderFooter: true,
headerTemplate: '<div style="font-size:8px;width:100%;padding:0 12mm;color:#555"><span class="title"></span></div>',
footerTemplate: '<div style="font-size:8px;width:100%;padding:0 12mm;text-align:right;color:#555">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
});
await browser.close();
console.log(`wrote ${out}`);
Three details trip people up here. The templates render at a tiny default font size, so set font-size inline. They don’t see your page stylesheet, so all styling has to be inline too. And Chromium draws them inside the page margin, so the script sets margin explicitly, because with the default zero margin the header and footer have nowhere to go.
The script does not print until an element with the id report-ready exists. That id is a placeholder: change READY_SELECTOR to an element your own template renders last. To try the script as written, create a small report that fills its table with JavaScript after 800 ms and only then adds the marker:
vim ~/pdf-node/report.html
Paste this test template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Monthly usage report</title>
<style>
@page { size: A4; }
body { font-family: sans-serif; font-size: 11pt; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #ccc; padding: 4px; text-align: left; }
</style>
</head>
<body>
<h1>Monthly usage report</h1>
<table><thead><tr><th>Service</th><th>Requests</th></tr></thead><tbody id="rows"></tbody></table>
<script>
setTimeout(() => {
const data = [["api-gateway", 412000], ["billing", 98000], ["search", 251000]];
document.getElementById("rows").innerHTML = data
.map(([name, n]) => `<tr><td>${name}</td><td>${n.toLocaleString("en-US")}</td></tr>`)
.join("");
const done = document.createElement("div");
done.id = "report-ready";
document.body.appendChild(done);
}, 800);
</script>
</body>
</html>
Render it:
cd ~/pdf-node
node render-pdf.mjs report.html report.pdf
The script reports the output file once the marker has appeared and the PDF is written:
wrote report.pdf
Check that the rows drawn by the delayed script and the footer both made it onto the page:
pdftotext report.pdf - | grep -E 'api-gateway|search|Page 1 of'
The rows and the page counter are there, so the wait held the print until the table was filled:
api-gateway
search
Page 1 of 1
The same options work in Python with snake_case names: display_header_footer, header_template and footer_template.
Wait for the page to finish rendering
This is where most HTML to PDF jobs lose content without any error. page.pdf() prints whatever the DOM holds at that moment, so the wait you choose before it decides what lands on paper. We loaded the dashboard page 5 times with each wait_until value and counted how often the timer-driven line appeared:
| Wait before page.pdf() | Synchronous JS rendered | 500 ms timer rendered | 2 s timer rendered |
|---|---|---|---|
commit | 3 of 5 | 0 of 5 | not tested |
domcontentloaded | 5 of 5 | 0 of 5 | not tested |
load (default) | 5 of 5 | 0 of 5 | not tested |
networkidle | 5 of 5 | 5 of 5 | 0 of 5 |
load + wait for the element | 5 of 5 | 5 of 5 | 5 of 5 |
Playwright’s own reference marks networkidle as DISCOURAGED and tells you to wait on page content instead, and these numbers show why. It looks like the safe choice until you notice why it worked: it waits for 500 ms without network activity, which happened to cover our 500 ms timer. Stretch the timer to 2 seconds and it captures nothing. It also adds that half second to every render. Wait for the thing you need instead. In Node.js that is page.getByText('...').waitFor() or page.waitForSelector(); in Python, page.get_by_text('...').wait_for(). If your template draws charts, have it set a flag such as window.renderDone = true when it finishes and wait on that with page.wait_for_function('window.renderDone === true').
page.pdf() options worth setting
The defaults are tuned for printing web pages, not documents. These are the options we set on almost every job, checked against the current page.pdf() reference:
| Node.js | Python | Default | Why you set it |
|---|---|---|---|
format | format | Letter | Outside North America you almost always want A4 |
printBackground | print_background | false | Without it, table header colours and card backgrounds disappear |
preferCSSPageSize | prefer_css_page_size | false | Lets @page { size: ... } and named landscape pages win |
margin | margin | none | Needed for header and footer templates |
displayHeaderFooter | display_header_footer | false | Turns on the two templates |
landscape | landscape | false | Whole document landscape (use a named @page for single pages) |
scale | scale | 1 | 0.1 to 2, the closest match to wkhtmltopdf --zoom |
tagged | tagged | false | Accessible PDF with a structure tree |
outline | outline | false | PDF bookmarks generated from headings |
pageRanges | page_ranges | all pages | Print a subset such as 1-3 |
page.pdf() uses print media. If a template only looks right on screen, call page.emulateMedia({ media: 'screen' }) (or page.emulate_media(media="screen")) before printing.
Keep untrusted HTML away from your files and network
The moment users can influence the HTML you render (an invoice note field, a Markdown comment, a template editor), the browser becomes a request proxy that runs inside your network. We fed a test page with an <iframe src="file:///etc/passwd">, an <object> pointing at the same file, and an image and stylesheet on http://127.0.0.1:8099, with a small listener on that port counting hits. A script tag on the same page also opened new WebSocket("ws://127.0.0.1:8099/ssrf-ws"), because WebSockets travel outside the normal request path.
| How the HTML reached Playwright | /etc/passwd lines in the PDF | Requests to 127.0.0.1 |
|---|---|---|
goto() on a file:// URL | 2 | 3 |
set_content(), no routing | 0 | 3 |
set_content() + route() abort only | 0 | 1 |
set_content() + route() + route_web_socket(), JS on | 0 | 0 |
set_content() + route() + route_web_socket(), JS off | 0 | 0 |
Loading from a file:// URL gives the page a file origin, and Chromium then happily prints local files into your PDF. set_content() fixes the file read because the page has no file origin, but the browser still fetches any URL the HTML names, including anything reachable from the host such as internal services. A route() handler that aborts requests stops the stylesheet and image, but the third row shows it doesn’t see WebSockets: the socket still reached the listener. route_web_socket() covers that gap, and a handler that never calls connect_to_server() keeps the socket mocked inside the browser. Service workers can also fetch outside the page’s routes, so the context blocks them. For templates that need no JavaScript, switching it off removes the whole class of script-driven requests. Create the hardened script:
vim ~/pdf-py/safe_pdf.py
Paste the hardened version. It disables JavaScript, blocks service workers, aborts every request and mocks every WebSocket, recording each URL it refuses:
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
html = Path(sys.argv[1]).read_text()
out = sys.argv[2]
blocked = []
def deny_request(route):
blocked.append(route.request.url)
route.abort()
def deny_websocket(ws):
# Never calling ws.connect_to_server() keeps the socket off the network.
blocked.append(ws.url)
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context(java_script_enabled=False, service_workers="block")
context.route("**/*", deny_request)
context.route_web_socket("**/*", deny_websocket)
page = context.new_page()
page.set_content(html, wait_until="load")
page.pdf(path=out, format="A4", print_background=True)
browser.close()
for url in blocked:
print("blocked:", url)
print(f"wrote {out}")
To check the script yourself, save a hostile test page next to it. It tries to read /etc/passwd two ways, loads a stylesheet and image from 127.0.0.1, and opens a WebSocket:
vim ~/pdf-py/security-ws.html
Paste the probe page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Security probe</title>
<link rel="attachment" href="file:///etc/passwd" title="passwd.txt">
<link rel="stylesheet" href="http://127.0.0.1:8099/ssrf-css.css">
</head>
<body>
<p>SEC_PROBE_START</p>
<iframe src="file:///etc/passwd" width="600" height="200"></iframe>
<object data="file:///etc/passwd" type="text/plain" width="600" height="200"></object>
<img src="http://127.0.0.1:8099/ssrf-img.png" alt="">
<script>new WebSocket("ws://127.0.0.1:8099/ssrf-ws");</script>
<p>MARKER_END</p>
</body>
</html>
Run the hardened script against it from the project directory:
cd ~/pdf-py
uv run python safe_pdf.py security-ws.html sec-safe.pdf
The stylesheet and image requests were refused and logged. The WebSocket never appears because the script that opens it never ran, and the listener on 127.0.0.1 recorded no requests at all:
blocked: http://127.0.0.1:8099/ssrf-css.css
blocked: http://127.0.0.1:8099/ssrf-img.png
wrote sec-safe.pdf
Our matrix also ran the same route and WebSocket handlers with JavaScript left on, which is what the API below needs for chart-drawing templates, and that row recorded zero requests too. Deny everything works when the template is self-contained: inline the CSS, and embed fonts and images as data: URIs, which do not pass through the route handler. If you need one asset host, check route.request.url against an allow list and call route.continue_() only for that host.
Serve PDFs over HTTP with FastAPI
Launching a browser per request is the slowest way to run Playwright. A long-lived service keeps one browser open and gives each request its own context, which isolates cookies and storage between users while skipping the launch cost. Set it up under /opt rather than your home directory. On Rocky Linux with SELinux enforcing, files under /home carry the user_home_t label, and systemd refuses to execute them. Our first attempt ran the unit from ~/pdf-api and failed with status=203/EXEC, and the journal showed why:
pdf-api.service: Failed at step EXEC spawning /home/eugene/pdf-api/.venv/bin/uvicorn: Permission denied
Building the service under /opt fixes it without any policy change. Two details matter. --python /usr/bin/python3 keeps the virtual environment on the system interpreter instead of a uv-managed Python stored in your home directory, and PLAYWRIGHT_BROWSERS_PATH puts Chromium under /opt too, because the browser cache also sits under your home directory by default, so keep it out of the service’s path too:
sudo mkdir -p /opt/pdf-api
sudo chown $USER: /opt/pdf-api
cd /opt/pdf-api
uv venv --python /usr/bin/python3
uv pip install playwright fastapi uvicorn
PLAYWRIGHT_BROWSERS_PATH=/opt/pdf-api/browsers uv run playwright install --only-shell chromium
Create the application file inside the project directory you just prepared:
vim /opt/pdf-api/app.py
Add the code below. The semaphore caps concurrent renders at four. Each request gets a fresh context with service workers blocked, every request aborted and every WebSocket mocked, so the hardening from the previous section applies. JavaScript stays on here because many report templates draw their charts with it. If your users supply the HTML, add java_script_enabled=False to new_context() as well: request and WebSocket routing does not cover every channel a script can open, such as WebRTC or DNS prefetching. If the browser process dies, the next request relaunches it under a lock, and /healthz reports both the connection state and how many relaunches have happened:
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from playwright.async_api import async_playwright
MAX_PARALLEL = 4
async def get_browser(app):
async with app.state.launch_lock:
if not app.state.browser.is_connected():
app.state.browser = await app.state.pw.chromium.launch()
app.state.relaunches += 1
return app.state.browser
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pw = await async_playwright().start()
app.state.browser = await app.state.pw.chromium.launch()
app.state.slots = asyncio.Semaphore(MAX_PARALLEL)
app.state.launch_lock = asyncio.Lock()
app.state.relaunches = 0
yield
await app.state.browser.close()
await app.state.pw.stop()
app = FastAPI(lifespan=lifespan)
async def deny_request(route):
await route.abort()
def deny_websocket(ws):
# Never calling ws.connect_to_server() keeps the socket off the network.
pass
@app.get("/healthz")
async def healthz():
return {"browser_connected": app.state.browser.is_connected(), "relaunches": app.state.relaunches}
@app.post("/pdf")
async def pdf(request: Request):
html = (await request.body()).decode()
async with app.state.slots:
browser = await get_browser(app)
context = await browser.new_context(service_workers="block")
try:
await context.route("**/*", deny_request)
await context.route_web_socket("**/*", deny_websocket)
page = await context.new_page()
await page.set_content(html, wait_until="load", timeout=15000)
data = await page.pdf(format="A4", print_background=True, prefer_css_page_size=True)
finally:
await context.close()
return Response(content=data, media_type="application/pdf")
Run it under systemd so it restarts with the server. Create the unit file:
sudo vim /etc/systemd/system/pdf-api.service
Add this unit, changing User to your account. The Environment line points Playwright at the browser you installed under /opt:
[Unit]
Description=Playwright HTML to PDF API
After=network-online.target
[Service]
User=eugene
WorkingDirectory=/opt/pdf-api
Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/pdf-api/browsers
ExecStart=/opt/pdf-api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8001
Restart=on-failure
[Install]
WantedBy=multi-user.target
Reload systemd so it sees the new unit, then enable the service and start it in one step:
sudo systemctl daemon-reload
sudo systemctl enable --now pdf-api
Check that the service came up:
systemctl status pdf-api --no-pager
The browser starts with the service, so memory is already around 200 MB before the first request:
● pdf-api.service - Playwright HTML to PDF API
Loaded: loaded (/etc/systemd/system/pdf-api.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-10-08 13:55:12 UTC; 1s ago
Invocation: 7d6931e557e14985861aab3c9b6cb8c0
Main PID: 265411 (uvicorn)
Tasks: 49 (limit: 8028)
Memory: 199.4M (peak: 200.5M)
CPU: 1.623s
Confirm that uvicorn runs as the account named in the unit, not as root:
ps -o user=,cmd= -C uvicorn
The process owner and the /opt paths both match the unit:
eugene /opt/pdf-api/.venv/bin/python /opt/pdf-api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8001
Ask the health endpoint whether the shared browser is still attached to the driver:
curl -s http://127.0.0.1:8001/healthz
A fresh service reports a connected browser and no relaunches yet:
{"browser_connected":true,"relaunches":0}
Now post an HTML document as the request body and save the response. The path must point at a real file; curl sends an empty body if it doesn’t exist:
curl -s -o invoice.pdf -w '%{http_code} %{size_download} bytes %{time_total}s\n' --data-binary @$HOME/pdf-py/invoice.html http://127.0.0.1:8001/pdf
Our 4-page test invoice came back in about 150 ms. On Rocky Linux, curl’s size_download may print 0 even when the file is fine, so check it with pdfinfo invoice.pdf:
200 53703 bytes 0.147654s
We repeated this setup on Rocky Linux 10 with SELinux enforcing: the unit started from /opt, ps showed it running as the unprivileged user, the same invoice rendered to a 4-page PDF, and ausearch logged no denials.
To prove the self-healing path, we killed the Chrome Headless Shell process behind the service. /healthz immediately reported {"browser_connected":false,"relaunches":0}, the next /pdf request still returned 200 in 0.20 seconds, and the health check then read:
{"browser_connected":true,"relaunches":1}
Systemd alone would not have caught this, because uvicorn kept running the whole time. If you drop the relaunch logic, point an external probe at /healthz and have it restart the service when browser_connected turns false.
The service listens on 127.0.0.1 only. Put it behind your reverse proxy with authentication, or call it from the application on the same host. If you would rather not maintain this code at all, Gotenberg ships the same idea as a ready-made container with LibreOffice conversion on top.
Package the service in Docker
Playwright’s Docker documentation is blunt about one thing: the image “is intended to be used for testing and development purposes only” and is not recommended for visiting untrusted websites. For crawling it recommends a separate user plus its seccomp profile so Chromium can sandbox. A PDF service that only renders your own templates, with outbound requests blocked as above, is a much narrower job than crawling, but if users supply the HTML, take that warning seriously and add the seccomp profile.
Microsoft publishes Playwright images with the browsers and system libraries already installed. The image tag must match the Python package version exactly, or Playwright looks for a browser build that is not in the image. Install Docker first if the host doesn’t have it (see Docker CE on Ubuntu 26.04 or Docker on Rocky Linux and AlmaLinux), then give the image its own build directory. Building from /opt/pdf-api would send the virtual environment and the browser folder to Docker as build context, so copy only app.py into a clean directory. The docker commands below assume your user is in the docker group (sudo usermod -aG docker $USER, then log in again); otherwise prefix them with sudo:
mkdir -p ~/pdf-docker
cp /opt/pdf-api/app.py ~/pdf-docker/
Create the Dockerfile in that directory:
vim ~/pdf-docker/Dockerfile
Use this Dockerfile. The base image runs as root by default, so the USER pwuser line drops privileges to the unprivileged account the image already provides:
ARG PW_VERSION
FROM mcr.microsoft.com/playwright/python:v${PW_VERSION}-noble
ARG PW_VERSION
WORKDIR /app
RUN pip install --no-cache-dir playwright==${PW_VERSION} fastapi uvicorn
COPY app.py .
USER pwuser
EXPOSE 8001
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
Detect the current Playwright release from PyPI so the base image and the pip pin always agree:
PW_VERSION=$(curl -s https://pypi.org/pypi/playwright/json | jq -r .info.version)
echo "$PW_VERSION"
The value printed here becomes both the base image tag and the pip pin inside the build. On a Playwright release day PyPI can publish a version a few hours before the matching image appears on the Microsoft registry; if the build can’t pull the base image, set PW_VERSION to the previous release for that run:
1.63.0
Build the image from the directory that holds only the Dockerfile and app.py:
cd ~/pdf-docker
docker build --build-arg PW_VERSION=${PW_VERSION} -t pdf-api:latest .
Check the size before you push it anywhere:
docker images pdf-api --format '{{.Repository}}:{{.Tag}} {{.Size}}'
The official base carries Chromium, Firefox and WebKit, which explains the weight:
pdf-api:latest 3.69GB
Stop the systemd copy if it is still running on port 8001, then start the container. Playwright’s Docker docs recommend --init to reap zombie browser processes and --ipc=host so Chromium does not run out of shared memory:
sudo systemctl disable --now pdf-api
docker run -d --name pdf-api --init --ipc=host -p 127.0.0.1:8001:8001 --restart unless-stopped pdf-api:latest
Send the same invoice to the container to confirm the request reaches the browser inside it:
curl -s -o invoice.pdf -w '%{http_code} %{size_download} bytes %{time_total}s\n' --data-binary @$HOME/pdf-py/invoice.html http://127.0.0.1:8001/pdf
Same output, served from inside the container:
200 53657 bytes 0.144570s
Confirm that the API and the browser inside the container aren’t running as root:
docker exec pdf-api id
The process runs as the image’s unprivileged user:
uid=1001(pwuser) gid=1001(pwuser) groups=1001(pwuser),100(users)
docker stats showed about 225 MiB in use once the browser had rendered a document.
Errors we hit on fresh servers
You are running Node.js 18.19.1. Playwright requires Node.js 20 or higher.
This is the default Node.js on Ubuntu 24.04. npm install playwright succeeds, then every npx playwright command prints this and exits. Install a current LTS from NodeSource as shown above, or follow the Node.js setup for Ubuntu 26.04 if you are moving to the newer release anyway.
sh: line 1: apt-get: command not found
playwright install --with-deps on Rocky Linux, AlmaLinux and RHEL. The full output ends with Failed to install browsers and Error: Installation process exited with code: 127. Install the libraries with the dnf command from the install section and rerun without --with-deps.
browserType.launch: Target page, context or browser has been closed
The browser died on launch. Scroll down in the same output and the real cause shows up in the browser log:
/home/eugene/.cache/ms-playwright/chromium_headless_shell-1248/chrome-headless-shell-linux64/chrome-headless-shell: error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directory
On Rocky Linux this means the dnf dependency step was skipped. On Ubuntu and Debian it means install ran without --with-deps; fix it with npx playwright install-deps chromium or the Python equivalent uv run playwright install-deps chromium.
Throughput: reuse the browser
Most of the cost of a Playwright PDF isn’t the PDF. It is starting the interpreter, the driver and Chromium. We rendered the same invoice 20 times on a 4 vCPU, 8 GB Ubuntu 26.04 VM with three strategies, all using the default load wait:
| Strategy | Median per PDF | p95 |
|---|---|---|
| New browser for every PDF | 323 ms | 382 ms |
| One browser, new context per PDF | 164 ms | 188 ms |
| One browser, one page reused | 102 ms | 157 ms |
Reusing a single page is fastest, but it leaks cookies, storage and service workers from one render into the next. A fresh context per render costs about 60 ms more and keeps tenants apart, and the FastAPI service above uses it for exactly that reason. A cold command-line run is slower still: in the cross-engine benchmark, a fresh Python process rendering the same invoice took a median of 1.27 seconds (1.46 seconds for Node.js), with about 190 to 200 MiB of working memory, because every run pays for interpreter start, browser launch and a networkidle wait.
Then we pushed 40 invoice requests through the FastAPI service at three client concurrency levels, restarting the service between runs:
| Client concurrency | PDFs per second | p50 latency | p95 latency | Service memory peak |
|---|---|---|---|---|
| 1 | 8.4 | 111 ms | 119 ms | 251 MiB |
| 4 | 16.8 | 221 ms | 263 ms | 349 MiB |
| 8 | 16.9 | 435 ms | 547 ms | 336 MiB |
Throughput doubles from one to four parallel renders and then flattens, because the semaphore holds the fifth request until a slot frees up. That is the behaviour you want: past the CPU count, extra parallel renders only stretch latency and memory. Size MAX_PARALLEL to the vCPUs you give the service, scale out with more containers rather than more slots, and let the relaunch logic or an external /healthz probe handle a crashed browser.
If you prefer the Chrome DevTools Protocol API that Playwright grew out of, the Puppeteer HTML to PDF guide covers the same jobs, including the AppArmor profile Puppeteer needs on current Ubuntu releases. Teams moving an old wkhtmltopdf setup will find the flag-by-flag mapping in the alternatives comparison.