Report-quality PDFs from plain HTML and CSS, no browser required. That’s WeasyPrint. You’ll install WeasyPrint, render a paged report with page numbers, running headers and a clickable table of contents, then wrap it in a small API and a Docker image. It takes about ten minutes.
WeasyPrint is the closest thing to a drop-in replacement for wkhtmltopdf when the HTML is yours: invoices, statements, reports. It passed all 15 paged-media CSS checks in the benchmark of wkhtmltopdf alternatives, and it’s a Python library first, so the same engine runs from the CLI, a script or a web service. Still on the old engine? The wkhtmltopdf install guide covers what’s left of it. Built and run with WeasyPrint 70.0 on Ubuntu 26.04, Ubuntu 24.04, Debian 13, Debian 12 and Rocky Linux 10 in October 2026.
Pick an install method
Distro packages lag. Badly, on the older releases. This is what apt-cache policy weasyprint and dnf info weasyprint returned on each test VM:
| Distro | Distro package | uv / PyPI |
|---|---|---|
| Ubuntu 26.04 | 67.0 | 70.0 |
| Ubuntu 24.04 | 61.1 | 70.0 |
| Debian 13 | 62.3 | 70.0 |
| Debian 12 | 57.2 | 70.0 |
| Rocky Linux 10 | not packaged (AppStream or EPEL) | 70.0 |
Use uv. It gets you the current release on every distro, keeps WeasyPrint in its own environment and does not touch system Python. The distro package is fine for a quick test on Ubuntu 26.04, nothing more.
Install on Ubuntu and Debian
WeasyPrint draws text through Pango. Install the shared libraries first, plus libharfbuzz-subset0 for font subsetting and poppler-utils for checking the output:
sudo apt update
sudo apt install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 fonts-dejavu-core poppler-utils curl vim
Install uv for your user:
curl -LsSf https://astral.sh/uv/install.sh | sh
The installer drops uv into ~/.local/bin and tells you how to load it:
downloading uv 0.12.23 x86_64-unknown-linux-gnu
installing to /home/joshua/.local/bin
uv
uvx
everything's installed!
To add $HOME/.local/bin to your PATH, either restart your shell or run:
source $HOME/.local/bin/env (sh, bash, zsh)
source $HOME/.local/bin/env.fish (fish)
Load it into the current shell, then install WeasyPrint as a tool:
source $HOME/.local/bin/env
uv tool install weasyprint
Thirteen packages, a few seconds:
Resolved 13 packages in 2.71s
Prepared 13 packages in 1.79s
Installed 13 packages in 17ms
+ brotli==1.2.0
+ cffi==2.1.1
+ cssselect2==0.10.1
+ fonttools==4.66.1
+ pillow==12.3.0
+ pycparser==3.0
+ pydyf==0.12.1
+ pyphen==0.18.1
+ tinycss2==1.5.1
+ tinyhtml5==2.1.0
+ weasyprint==70.0
+ webencodings==0.6.1
+ zopfli==0.4.3
Installed 1 executable: weasyprint
Check the build. --info also prints the Python and Pango it found:
weasyprint --info
On Ubuntu 26.04 that’s Python 3.14 and Pango 1.57:
System: Linux
Machine: x86_64
Version: #15-Ubuntu SMP PREEMPT_DYNAMIC Wed Apr 22 16:06:43 UTC 2026
Release: 7.0.0-15-generic
WeasyPrint version: 70.0
Python version: 3.14.4
Pydyf version: 0.12.1
Pango version: 15700
The same commands worked unchanged on Ubuntu 24.04 (Python 3.12, Pango 1.52), Debian 13 (Python 3.13) and Debian 12 (Python 3.11, Pango 1.50). WeasyPrint needs Python 3.10 or newer and Pango 1.44 or newer, so all four clear the bar.
Install on Rocky Linux 10
Rocky needs Pango from AppStream plus the DejaVu fonts the examples use. HarfBuzz comes in as a dependency and already ships the subset library:
sudo dnf install -y pango dejavu-sans-fonts poppler-utils vim-minimal
curl -LsSf https://astral.sh/uv/install.sh | sh
Skip the source step here. Rocky’s default ~/.bashrc already puts ~/.local/bin on PATH, so the installer does not write an env file and source $HOME/.local/bin/env fails with “No such file or directory”. Install straight away:
uv tool install weasyprint
weasyprint --version
It reports the same release as Ubuntu:
WeasyPrint version 70.0
Skip dejavu-sans-fonts and nothing fails. Fontconfig quietly substitutes Noto Sans for every DejaVu Sans rule, so the PDF looks slightly different from the one on Ubuntu. SELinux stayed enforcing for every test here, Docker included. ausearch -m avc logged nothing from WeasyPrint.
OSError: cannot load library ‘libpango-1.0-0’
That’s what you get if you skip pango on Rocky, or run WeasyPrint in a minimal container:
OSError: cannot load library 'libpango-1.0-0': libpango-1.0-0: cannot open shared object file: No such file or directory. Additionally, ctypes.util.find_library() did not manage to locate a library called 'libpango-1.0-0'
The pip wheel bundles no C libraries. Install pango (RHEL family) or libpango-1.0-0 libpangoft2-1.0-0 (Debian family) and rerun. No reinstall needed.
Convert HTML to PDF
Make a working directory and grab a web font to prove font embedding works:
mkdir -p ~/pdfdemo && cd ~/pdfdemo
curl -sLo Lobster-Regular.ttf https://github.com/google/fonts/raw/main/ofl/lobster/Lobster-Regular.ttf
Create the report. Any editor works, vim is used here:
vim report.html
Paste this. It’s ordinary HTML with a nav list, a KPI grid, a footnote and a wide table:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quarterly Infrastructure Report</title>
<link rel="stylesheet" href="print.css">
</head>
<body>
<h1>Quarterly Report</h1>
<nav>
<ol>
<li><a href="#summary">Summary</a></li>
<li><a href="#capacity">Capacity</a></li>
<li><a href="#services">Service matrix</a></li>
</ol>
</nav>
<h2 id="summary">Summary</h2>
<div class="kpis">
<div><b>99.98%</b>API availability</div>
<div><b>412</b>Deploys</div>
<div><b>3</b>Sev-2 incidents</div>
</div>
<p>The platform held its availability target for the third quarter running.<span class="fn">One storage incident in August used most of the error budget.</span> Median deploy lead time dropped from 41 to 17 minutes.</p>
<h2 id="capacity">Capacity</h2>
<p>Compute utilisation averaged 61 percent, with month-end peaks of 83 percent. Memory is the binding constraint on the analytics nodes.</p>
<section class="wide">
<h2 id="services">Service matrix</h2>
<table>
<thead>
<tr><th>Service</th><th>Owner</th><th>Tier</th><th>Region A</th><th>Region B</th><th>RPO</th><th>RTO</th><th>Last DR test</th></tr>
</thead>
<tbody>
<tr><td>api-gateway</td><td>platform</td><td>1</td><td>active</td><td>active</td><td>0</td><td>5m</td><td>2026-09-02</td></tr>
<tr><td>billing</td><td>payments</td><td>1</td><td>active</td><td>standby</td><td>1m</td><td>15m</td><td>2026-08-21</td></tr>
<tr><td>search</td><td>discovery</td><td>2</td><td>active</td><td>active</td><td>15m</td><td>30m</td><td>2026-07-30</td></tr>
</tbody>
</table>
</section>
</body>
</html>
Now the print stylesheet. All the paged-media work lives here:
vim print.css
Add the following:
@font-face {
font-family: "Lobster";
src: url("Lobster-Regular.ttf");
}
@page {
size: A4;
margin: 25mm 18mm;
@top-right { content: "Chapter: " string(chapter); font-size: 9pt; color: #666; }
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; }
}
@page :first { @top-right { content: none; } }
@page wide { size: A4 landscape; }
body { font-family: "DejaVu Sans", sans-serif; font-size: 10.5pt; }
h1 { font-family: "Lobster", cursive; font-weight: normal; color: #0b5cad; font-size: 34pt; }
h2 { string-set: chapter content(); break-before: page; color: #0b5cad; }
nav ol { list-style: none; padding: 0; }
nav a { color: inherit; text-decoration: none; }
nav a::after { content: leader(".") target-counter(attr(href), page); }
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6mm; }
.kpis div { border: 2px solid #0b5cad; border-radius: 6px; padding: 4mm; text-align: center; }
.kpis b { display: block; font-size: 18pt; }
.fn { float: footnote; font-size: 8pt; }
.wide { page: wide; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #bbb; padding: 3px 6px; }
th { background: #0b5cad; color: #fff; }
Render it:
weasyprint report.html report.pdf
Silence means success. Check the page sizes. The last page should be landscape, because the service matrix sits on the named wide page:
pdfinfo -f 1 -l 4 report.pdf | grep 'size'
Pages 1 to 3 are portrait A4, page 4 flips:
Page 1 size: 595.276 x 841.89 pts (A4)
Page 2 size: 595.276 x 841.89 pts (A4)
Page 3 size: 595.276 x 841.89 pts (A4)
Page 4 size: 841.89 x 595.276 pts (A4)
File size: 21151 bytes
21 KB for four pages and an embedded web font. Small files are a WeasyPrint habit: on the benchmark’s own report document it wrote 23 KB, while the Chromium-based tools wrote 85 to 111 KB.
Add page numbers, headers and a TOC
Five CSS features did the heavy work in that stylesheet, and none of them worked in wkhtmltopdf:
@bottom-centerwithcounter(page)andcounter(pages)prints “Page X of Y” in the page margin.string-set: chapter content()on eachh2feeds the running header in@top-right.target-counter(attr(href), page)withleader(".")builds a dotted table of contents with real page numbers.float: footnotemoves the span to the bottom of its page and numbers it.page: widesends one section to a landscape page without touching the rest.
See them in the text layer. Page 1 has the TOC with resolved page numbers:
pdftotext -layout report.pdf - | head -12
Dotted leaders, page numbers, then the footer and the next page’s running header:
Quarterly Report
Summary ............................................................................................................................. 2
Capacity ............................................................................................................................... 3
Service matrix ...................................................................................................................... 4
Page 1 of 4
Chapter: Summary
Page 2 shows the grid, the footnote and the header together:
pdftotext -f 2 -l 2 -layout report.pdf - | grep -v '^\s*$'
Three KPI cards side by side, the footnote marker 1 in the text, its note at the foot of the page:
Chapter: Summary
Summary
99.98% 412 3
API availability Deploys Sev-2 incidents
The platform held its availability target for the third quarter running.1 Median deploy lead
time dropped from 41 to 17 minutes.
1. One storage incident in August used most of the error budget.
Page 2 of 4
PDF bookmarks come free. WeasyPrint builds the outline from your heading levels, so h1 becomes the root and each h2 a child, the same tree Document.make_bookmark_tree() returns in Python.
Embed fonts
Fonts come from two places: @font-face rules and anything fontconfig knows about. Confirm both made it into the PDF:
pdffonts report.pdf
Lobster came from the @font-face file, DejaVu from the system. All are embedded and subset:
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
OPNJKW+Lobster CID TrueType Identity-H yes yes yes 34 0
XKQQSR+DejaVu-Sans CID TrueType Identity-H yes yes yes 38 0
BAWKYD+DejaVu-Serif CID TrueType Identity-H yes yes yes 42 0
UWGIWA+DejaVu-Sans-Bold CID TrueType Identity-H yes yes yes 46 0
Leave font-weight: normal on any heading that uses a single-weight web font. Without it, the browser-default bold on h1 made WeasyPrint embed the face as Lobster-Bold, a synthetic bold of the regular file. For system fonts, drop the files into /usr/local/share/fonts or ~/.local/share/fonts and run fc-cache -f. WeasyPrint picks them up by family name.
WARNING: Using fontTools instead of HarfBuzz-Subset
You’ll see this on Ubuntu or Debian when libharfbuzz-subset0 is missing. The apt weasyprint package doesn’t pull it in either:
WARNING: Using fontTools instead of HarfBuzz-Subset for font "Lobster". This will be unsupported in future versions of WeasyPrint. Please install HarfBuzz-Subset >= 4.1.0 with your package manager.
WARNING: Using fontTools instead of HarfBuzz-Subset for font "DejaVu Sans". This will be unsupported in future versions of WeasyPrint. Please install HarfBuzz-Subset >= 4.1.0 with your package manager.
It is not cosmetic. With the library installed, the 5-page invoice benchmark dropped from 1.47 s to 1.31 s per CLI run (median of 5), about 11 percent. Fix it with one package:
sudo apt install -y libharfbuzz-subset0
Produce PDF/A and attachments
Archival and e-invoicing systems often demand PDF/A. One flag:
weasyprint --pdf-variant pdf/a-3b report.html report-pdfa.pdf
pdfinfo -meta report-pdfa.pdf | grep -i pdfaid
The XMP metadata now declares PDF/A part 3, conformance B:
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" pdfaid:part="3" pdfaid:conformance="B" /><rdf:Description rdf:about="" pdf:Producer="WeasyPrint 70.0" /><rdf:Description rdf:about=""><dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Infrastructure Report</rdf:li></rdf:Alt></dc:title></rdf:Description></rdf:RDF>
That’s the declaration, not a certificate. Run a validator such as veraPDF before you promise compliance to anyone. Other variants on the same flag include pdf/a-2b, pdf/a-4u, pdf/ua-1 and pdf/x-4.
PDF/A-3 allows embedded files, which is how ZUGFeRD and Factur-X invoices carry their XML. Attach a file with -a:
printf 'service,tier,rpo\napi-gateway,1,0\nbilling,1,1m\nsearch,2,15m\n' > services.csv
weasyprint -a services.csv report.html report-attach.pdf
pdfdetach -list report-attach.pdf
The CSV rides inside the PDF:
1 embedded files
1: services.csv
Use the WeasyPrint Python API
The CLI is a thin wrapper. Apps call the library. Set up a project environment:
uv venv --allow-existing
uv pip install weasyprint flask
The --allow-existing flag makes the command safe to re-run. A plain uv venv on an existing .venv stops with “A virtual environment already exists”. Create the script:
vim make_pdf.py
It renders an HTML string with an inline stylesheet to a file, then renders the report file to bytes in memory, which is what a web handler returns:
from weasyprint import CSS, HTML
rows = [("Managed PostgreSQL", 2, 144.00), ("Object storage 500 GB", 4, 31.00), ("Load balancer", 1, 44.00)]
body = "".join(f"<tr><td>{name}</td><td>{qty}</td><td>{price * qty:.2f}</td></tr>" for name, qty, price in rows)
total = sum(price * qty for _, qty, price in rows)
html = f"""
<h1>Invoice INV-2026-1042</h1>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
<tbody>{body}</tbody>
</table>
<p class="total">Total: {total:.2f} USD</p>
"""
css = CSS(string="""
@page { size: A4; margin: 20mm; @bottom-right { content: counter(page) " / " counter(pages); } }
body { font-family: "DejaVu Sans", sans-serif; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #ccc; padding: 4px; text-align: left; }
.total { font-weight: bold; text-align: right; }
""")
HTML(string=html, base_url=".").write_pdf("invoice.pdf", stylesheets=[css])
pdf_bytes = HTML(filename="report.html").write_pdf()
print(f"invoice.pdf written, report rendered in memory: {len(pdf_bytes)} bytes")
Run it inside the venv:
.venv/bin/python make_pdf.py
pdftotext -layout invoice.pdf - | head -8
Both renders worked. The invoice text layer reads back cleanly:
invoice.pdf written, report rendered in memory: 21151 bytes
Invoice INV-2026-1042
Item Qty Amount
Managed PostgreSQL 2 288.00
Object storage 500 GB 4 124.00
Load balancer 1 44.00
Total: 456.00 USD
Always pass base_url with HTML(string=...). Without it, relative links to CSS, images and fonts resolve to nothing. And keep the process alive between renders. In the benchmark (Ubuntu 26.04, 4 vCPU) the report document took 237 ms per render inside a long-running process (median of 19 warm renders) against 0.95 s as a fresh CLI process (median of 5). Interpreter and font setup is most of the CLI cost.
Block file reads and SSRF
Skip this section only if every byte of HTML you render is yours. The default fetcher follows file:// URLs and any HTTP address. Here’s a hostile input:
vim evil.html
It asks for /etc/passwd as a PDF attachment and pokes a localhost port:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice</title>
<link rel="attachment" href="file:///etc/passwd" title="passwd.txt">
</head>
<body>
<p>Totally normal invoice.</p>
<img src="http://127.0.0.1:8099/internal-only.png" alt="">
</body>
</html>
Render it with defaults and list the attachments:
weasyprint evil.html leak.pdf
pdfdetach -list leak.pdf
pdfdetach -save 1 -o leaked.txt leak.pdf && head -3 leaked.txt
The PDF carries the server’s password file. The connection refused error also proves WeasyPrint tried the internal URL:
ERROR: Failed to load image at 'http://127.0.0.1:8099/internal-only.png': URLError: <urlopen error [Errno 111] Connection refused>
1 embedded files
1: passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
Same result on all five distros. The WeasyPrint security notes document it. On the CLI, restrict protocols:
weasyprint --allowed-protocols https,data evil.html cli-safe.pdf
pdfdetach -list cli-safe.pdf
Both fetches are refused and nothing is attached:
ERROR: Failed to load image at 'http://127.0.0.1:8099/internal-only.png': ValueError: URI uses disallowed protocol: http://127.0.0.1:8099/internal-only.png
ERROR: Failed to load attachment: ValueError: URI uses disallowed protocol: file:///etc/passwd
0 embedded files
That flag also blocks your own local CSS and fonts, and it still lets https:// reach private IPs. Apps need a smarter fetcher. Create it:
vim safe_fetcher.py
It allows data: URIs, files under an assets folder next to the script, and HTTPS hosts that resolve only to public addresses:
import ipaddress
import socket
from pathlib import Path
from urllib.parse import unquote, urlparse
from weasyprint.urls import URLFetcher
ASSETS = (Path(__file__).resolve().parent / "assets").resolve()
class SafeFetcher(URLFetcher):
"""Allow data: URIs, files under ./assets and public HTTPS hosts. Refuse the rest."""
def fetch(self, url, headers=None):
parsed = urlparse(url)
if parsed.scheme == "data":
return super().fetch(url, headers)
if parsed.scheme == "file":
path = Path(unquote(parsed.path)).resolve()
if path.is_relative_to(ASSETS):
return super().fetch(url, headers)
raise ValueError(f"blocked local file: {path}")
if parsed.scheme == "https" and parsed.hostname:
for info in socket.getaddrinfo(parsed.hostname, parsed.port or 443):
address = ipaddress.ip_address(info[4][0])
if not address.is_global:
raise ValueError(f"blocked non-public address: {address}")
return super().fetch(url, headers)
raise ValueError(f"blocked scheme: {parsed.scheme}")
A small driver uses it, with the base URL pointed at assets:
vim secure_pdf.py
Paste:
import sys
from pathlib import Path
from weasyprint import HTML
from safe_fetcher import ASSETS, SafeFetcher
source = Path(sys.argv[1]).read_text()
HTML(string=source, base_url=ASSETS.as_uri() + "/", url_fetcher=SafeFetcher()).write_pdf(sys.argv[2])
print(f"wrote {sys.argv[2]}")
Move the trusted assets into place and try the hostile file again:
mkdir -p assets && cp Lobster-Regular.ttf print.css assets/
.venv/bin/python secure_pdf.py evil.html safe.pdf
pdfdetach -list safe.pdf
No attachment this time:
wrote safe.pdf
0 embedded files
The real report still renders through the same fetcher, because its CSS and font now live in assets:
.venv/bin/python secure_pdf.py report.html secure-report.pdf
pdfinfo secure-report.pdf | grep Pages
All four pages come back:
wrote secure-report.pdf
Pages: 4
Resolving the name and then fetching leaves a small DNS-rebinding window. Close it at the network layer too: run the renderer where it has no route to metadata endpoints or internal subnets. A container with an egress policy does that.
Serve PDFs over HTTP
A PDF endpoint is twenty lines of Flask on top of the fetcher:
vim app.py
It takes raw HTML in the request body and returns the PDF. The 2 MB body cap keeps someone from posting a novel:
from flask import Flask, Response, request
from weasyprint import HTML
from safe_fetcher import ASSETS, SafeFetcher
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024
@app.post("/pdf")
def pdf():
source = request.get_data(as_text=True)
document = HTML(string=source, base_url=ASSETS.as_uri() + "/", url_fetcher=SafeFetcher())
return Response(document.write_pdf(), mimetype="application/pdf")
@app.get("/healthz")
def healthz():
return {"status": "ok"}
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8003)
Start it:
.venv/bin/python app.py
From a second terminal in ~/pdfdemo, hit it with a one-liner and with the hostile file:
curl -s 127.0.0.1:8003/healthz
curl -s -X POST --data-binary '<h1>Hello from WeasyPrint</h1>' -o hello.pdf 127.0.0.1:8003/pdf
pdfinfo hello.pdf | grep -E 'Producer|Pages'
curl -s -X POST --data-binary @evil.html -o evil-api.pdf 127.0.0.1:8003/pdf
pdfdetach -list evil-api.pdf
Healthy, rendering, and refusing the attachment trick:
{"status":"ok"}
Producer: WeasyPrint 70.0
Pages: 1
0 embedded files
Flask’s built-in server prints its own “development server” warning. Believe it. Stop it with Ctrl+C. The Docker image below runs the same app under gunicorn.
Run WeasyPrint in Docker
There is no official WeasyPrint image, and you do not need one. Docker Engine must already be installed (Docker on Ubuntu 26.04, Docker on Rocky Linux). Set up a build context with the app files and assets:
mkdir -p ~/weasyprint-api/assets
cp app.py safe_fetcher.py ~/weasyprint-api/
cp Lobster-Regular.ttf print.css ~/weasyprint-api/assets/
cd ~/weasyprint-api
Create the Dockerfile:
vim Dockerfile
Slim Python base, the same three libraries as the host install, gunicorn in front:
FROM python:3-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir weasyprint flask gunicorn
ENV HOME=/tmp
WORKDIR /app
COPY safe_fetcher.py app.py ./
COPY assets ./assets
USER nobody
EXPOSE 8003
CMD ["gunicorn", "--bind", "0.0.0.0:8003", "--workers", "2", "--timeout", "60", "app:app"]
Build it, then run it read-only with a memory cap. The --timeout 60 on gunicorn kills runaway renders, which the WeasyPrint docs recommend for untrusted input:
sudo docker build -t weasyprint-api .
sudo docker run -d --name weasyprint-api -p 127.0.0.1:8003:8003 --read-only --tmpfs /tmp --memory 512m weasyprint-api
Send the full report through it. The CSS and font resolve from the image’s assets folder:
cd ~/pdfdemo
curl -s -X POST --data-binary @report.html -o docker-report.pdf 127.0.0.1:8003/pdf
pdfinfo docker-report.pdf | grep -E 'Producer|Pages'
sudo docker logs weasyprint-api
Four styled pages back, and a clean startup log:
Producer: WeasyPrint 70.0
Pages: 4
[2026-10-08 10:29:05 +0000] [1] [INFO] Starting gunicorn 26.2.0
[2026-10-08 10:29:05 +0000] [1] [INFO] Listening at: http://0.0.0.0:8003 (1)
[2026-10-08 10:29:05 +0000] [1] [INFO] Using worker: sync
[2026-10-08 10:29:05 +0000] [7] [INFO] Booting worker with pid: 7
[2026-10-08 10:29:05 +0000] [8] [INFO] Booting worker with pid: 8
[2026-10-08 10:29:05 +0000] [1] [INFO] Control socket listening at /tmp/.gunicorn/gunicorn.ctl
The image came to 324 MB, and docker stats showed about 120 MiB in use after a render with two workers. The same build and run passed on Rocky Linux 10 with SELinux enforcing. Want a point-and-click PDF toolbox for people rather than an API for apps? Stirling-PDF in Docker is that.
Fontconfig error: No writable cache directories
Leave out ENV HOME=/tmp and a --read-only container floods its log with this, once per font lookup:
Fontconfig error: No writable cache directories
Fontconfig error: No writable cache directories
Fontconfig error: No writable cache directories
User nobody has /nonexistent as its home, so fontconfig cannot write ~/.cache. Recent gunicorn hits the same wall from a different angle, at boot:
[2026-10-08 10:28:27 +0000] [1] [ERROR] Control server error: [Errno 30] Read-only file system: '/nonexistent'
Pointing HOME at the tmpfs fixes both. Setting only XDG_CACHE_HOME silenced fontconfig but left the gunicorn error.
JavaScript doesn’t run
WeasyPrint is a layout engine, not a browser. Scripts are ignored. A two-line test makes the point:
vim js-test.html
Paste:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>JS test</title></head>
<body>
<p id="out">Static HTML only</p>
<script>document.getElementById("out").textContent = "JavaScript ran";</script>
</body>
</html>
Render and read the text back:
weasyprint js-test.html js-test.pdf && pdftotext js-test.pdf -
The script never ran:
Static HTML only
Render charts server-side (SVG works fine) or pre-render the page. If the page truly needs a browser, use Playwright’s page.pdf() or the Gotenberg API. Both execute JavaScript before printing.
wkhtmltopdf flags in WeasyPrint
Most wkhtmltopdf switches become CSS. Keep this card next to the migration:
| wkhtmltopdf | WeasyPrint |
|---|---|
-s A4, --page-size | @page { size: A4; } |
-O Landscape | @page { size: A4 landscape; }, or a named page for one section |
-T -B -L -R margins | @page { margin: 25mm 18mm; } |
--header-right "[page]/[topage]" | @top-right { content: counter(page) "/" counter(pages); } |
--header-html, --footer-html | Margin boxes plus string-set for running text |
toc | target-counter(attr(href), page) with leader(".") |
--outline, --outline-depth | Same default: bookmarks follow h1 to h6. Use bookmark-level: none to drop a heading |
--print-media-type | Default: @media print. Use -m screen for screen styles |
--user-style-sheet | -s, --stylesheet |
--encoding | -e, --encoding |
--dpi, --image-quality | -D, -j, --optimize-images |
--title | The HTML <title> goes into PDF metadata |
--enable-local-file-access | File access is on by default. Restrict it with --allowed-protocols or a custom fetcher |
--custom-header, --cookie | URLFetcher(http_headers={...}) in Python |
--javascript-delay | No equivalent. No JavaScript |
--disable-smart-shrinking, --zoom | Not needed: layout uses real CSS units. write_pdf(zoom=...) if you must |
The usual surprise in a migration is shrinking. wkhtmltopdf scaled pages down by default, so old templates sized for “fit” come out larger and longer in WeasyPrint. Set explicit font-size and @page margins, render once, and compare page counts before you switch the pipeline over. Source and issues live on GitHub.