PDF generation is the job nobody wants running inside the web app process. It pulls in a browser engine, it eats memory in bursts, and when it hangs it takes a request worker down with it. Gotenberg moves all of that into one Docker container with an HTTP API: you POST an index.html (or a URL, a Markdown file, a Word document), and you get a PDF back. I’ve been running it in my homelab as the PDF backend for invoices and runbooks, and it’s the closest thing to a drop-in replacement for shelling out to wkhtmltopdf.
This Gotenberg HTML to PDF guide runs the API in Docker and Docker Compose, converts HTML with fonts and images, waits for JavaScript charts, adds page numbers through header and footer templates, converts Markdown, DOCX and live URLs, merges PDFs, produces PDF/A, and then locks the API down with basic auth, outbound IP filtering, queue limits and a read-only container. It also covers rootless Podman on Rocky Linux with SELinux enforcing. If you’re still deciding which engine to use, our benchmark of wkhtmltopdf alternatives ranks Gotenberg against WeasyPrint, Playwright and eight other tools on the same documents.
Ran all of this against Gotenberg 8.37.0 on Ubuntu 26.04, Ubuntu 24.04, Debian 13 and Rocky Linux 10 in October 2026, and every request below came back as a real PDF.
How Gotenberg fits in front of your app
Gotenberg is a Go API server that drives two engines inside the same container: headless Chromium for HTML, URLs and Markdown, and LibreOffice for Office documents. A set of PDF tools (qpdf, pdfcpu, pdftk, exiftool) handles merging, metadata and PDF/A post-processing. Your application never touches a browser. It sends a multipart form to port 3000 and streams the PDF back to the user. (If you want a point-and-click PDF toolbox for people rather than an API for code, Stirling-PDF is the better fit.)
That model matters for security too. Since the HTML renders inside an isolated container, a hostile template can only reach what the container can reach, and Gotenberg ships with a default deny list that refuses file:// URLs outside its own temp directory. In our security probe, an <iframe src="file:///etc/passwd"> that leaked the host’s password file through Puppeteer and the Chrome CLI (when the page was opened from a file:// path) came back empty from Gotenberg, with this line in the container log:
"msg":"'file:///etc/passwd' matches the expression from the denied list: value filtered"
Our benchmark box (4 vCPU, 8 GB, Ubuntu 26.04) recorded these numbers on the three test documents from the alternatives roundup. Each figure is the median of five requests against a warm container.
| Measurement | Result |
|---|---|
Cold start, docker run to a passing /health | 303 to 377 ms |
| Invoice (4 pages, web font, 140 table rows) | 0.26 s per request |
| Report (5 pages, grid, columns, landscape page) | 0.22 s per request |
JS dashboard with waitDelay=1s | 1.20 s per request |
| Container memory peak while rendering | 233 to 310 MB |
| Idle memory with Chromium pre-started | 150 MiB |
1. Prepare Docker, tools and a working directory
Gotenberg only needs a container runtime. If Docker is not on the box yet, follow our guides for Docker CE on Ubuntu or Docker on Rocky Linux and AlmaLinux, then come back. Rootless Podman works too, and section 10 covers it.
The examples also use jq to read JSON and the poppler tools (pdffonts, pdfinfo, pdftotext) to check what came back. On Ubuntu and Debian:
sudo apt install -y jq poppler-utils python3-venv
On Rocky Linux, AlmaLinux and RHEL:
sudo dnf install -y jq poppler-utils
The Node.js client in section 9 needs Node 18 or newer; our Node.js install guide covers it. Curl and Python users can ignore it.
Everything below runs from one working directory. Create it and download the Lobster font that the test invoice uses as a web font:
mkdir -p ~/pdf-api && cd ~/pdf-api
curl -sLo Lobster-Regular.ttf https://github.com/google/fonts/raw/main/ofl/lobster/Lobster-Regular.ttf
The project publishes three Gotenberg Docker image variants. The full image is what you want unless you are certain you will never convert an Office file, because the size difference is smaller than you’d guess:
| Tag | Engines inside | Compressed size (amd64) |
|---|---|---|
gotenberg/gotenberg:8 | Chromium, LibreOffice, PDF tools | 669 MiB |
gotenberg/gotenberg:8-chromium | Chromium, PDF tools | 480 MiB |
gotenberg/gotenberg:8-libreoffice | LibreOffice, PDF tools | 422 MiB |
The :8 tag tracks the latest 8.x release, so a docker compose pull picks up fixes without you editing anything. Pin an exact tag (from the Gotenberg releases page) only if your PDFs must stay byte-identical across upgrades.
2. Start Gotenberg and check its health
One command gets you a working API. Bind it to 127.0.0.1: ports that Docker publishes on all interfaces skip UFW and firewalld rules, so a plain -p 3000:3000 would expose an unauthenticated PDF renderer to your whole network.
docker run -d --name gotenberg --restart unless-stopped -p 127.0.0.1:3000:3000 gotenberg/gotenberg:8
The health route reports both engines separately, which is handy for load balancer probes:
curl -s http://localhost:3000/health | jq .
Both modules should report up:
{
"status": "up",
"details": {
"chromium": {
"status": "up",
"timestamp": "2026-10-08T11:03:06.420087736Z"
},
"libreoffice": {
"status": "up",
"timestamp": "2026-10-08T11:03:06.420091128Z"
}
}
}
The version route returns a bare string, which makes it easy to log from a deploy script:
curl -s http://localhost:3000/version
Our lab pulled the current 8.x build:
8.37.0
3. Gotenberg HTML to PDF with fonts and images
Create a small invoice to test with. It uses the Lobster web font for the heading, CSS margin boxes for a running header and page counter, and a coloured table header:
vim invoice.html
Paste the template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice INV-1042</title>
<style>
@font-face { font-family: "Lobster"; src: url("Lobster-Regular.ttf"); }
@page {
size: A4;
margin: 20mm 15mm;
@top-left { content: "ACME Hosting Ltd"; font-size: 9pt; color: #555; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #555; }
}
body { font-family: sans-serif; font-size: 10pt; }
h1 { font-family: "Lobster", cursive; font-size: 30pt; color: #0b5cad; margin: 0 0 8mm; }
table { width: 100%; border-collapse: collapse; }
th { background: #0b5cad; color: #fff; text-align: left; padding: 5px; }
td { padding: 4px 5px; border-bottom: 1px solid #ddd; }
.num { text-align: right; }
</style>
</head>
<body>
<h1>Invoice</h1>
<p>INV-1042 for Example Corp, issued 2026-10-08.</p>
<table>
<thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Amount</th></tr></thead>
<tbody>
<tr><td>Managed PostgreSQL</td><td class="num">2</td><td class="num">288.00</td></tr>
<tr><td>Object storage 500 GB</td><td class="num">1</td><td class="num">31.00</td></tr>
<tr><td>Load balancer</td><td class="num">1</td><td class="num">44.00</td></tr>
<tr><td><strong>Total</strong></td><td></td><td class="num"><strong>363.00</strong></td></tr>
</tbody>
</table>
</body>
</html>
The HTML route wants the main file to be called index.html. You don’t have to rename anything on disk: curl’s ;filename= suffix sets the upload name, which keeps several templates in one directory. The catch is that Gotenberg stores every uploaded file in one flat directory, so assets must be referenced by filename only (url("Lobster-Regular.ttf"), never fonts/Lobster-Regular.ttf) and uploaded in the same request. A font path that does not exist inside the container silently falls back to a system font. Convert the invoice:
curl -s -o invoice.pdf \
--form "[email protected];filename=index.html" \
--form [email protected] \
--form preferCssPageSize=true \
--form printBackground=true \
http://localhost:3000/forms/chromium/convert/html
Two fields matter on almost every request. preferCssPageSize=true makes Chromium honour your CSS @page size and margins instead of the API’s US Letter default, and printBackground=true keeps the blue table header, which Chromium drops by default in print mode. Check that the font made it in:
pdffonts invoice.pdf
The Lobster face is embedded next to the fonts Chromium picked for the body text:
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+LiberationSerif CID TrueType Identity-H yes yes yes 4 0
BAAAAA+Lobster-Regular Type 3 Custom yes yes yes 5 0
BAAAAA+Lobster-Regular Type 3 Custom yes yes yes 6 0
CAAAAA+LiberationSans CID TrueType Identity-H yes yes yes 7 0
DAAAAA+LiberationSans-Bold CID TrueType Identity-H yes yes yes 8 0
The CSS margin boxes rendered as well, because the Chromium build inside Gotenberg supports CSS page margin boxes (wkhtmltopdf never did):
pdftotext -layout invoice.pdf - | grep -E "ACME|Page"
Both the running header and the counter are in the text layer:
ACME Hosting Ltd
Page 1 of 1
One thing caught me on the very first request: it took 0.79 seconds, while the identical request right after finished in 0.21 seconds. By default Gotenberg starts Chromium lazily on the first conversion, so whoever hits the API first after a restart pays for the browser launch. The Compose file in section 8 sets --chromium-auto-start=true to move that cost to container start.
4. Wait for JavaScript before printing
Chromium prints as soon as the page fires its load event, which is too early for anything drawn by a timer or a fetch. This test page draws a bar chart on load and switches its status line to “Report ready” 500 ms later, setting window.status as it does:
vim dashboard.html
Add the page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Daily requests</title>
</head>
<body>
<h1>Daily requests</h1>
<div id="chart"></div>
<p id="status">Loading</p>
<script>
const data = [42, 55, 61, 48, 72, 90, 85];
document.getElementById('chart').innerHTML = '<svg width="360" height="120">' +
data.map((v, i) => `<rect x="${i * 50}" y="${120 - v}" width="40" height="${v}" fill="#0b5cad"/>`).join('') + '</svg>';
setTimeout(() => {
document.getElementById('status').textContent = 'Report ready';
window.status = 'ready';
}, 500);
</script>
</body>
</html>
Here is how the three options compared on that page:
| Request option | Time | Status line in the PDF |
|---|---|---|
| No wait option | 0.18 s | Loading |
waitDelay=1s | 1.19 s | Report ready |
waitForExpression=window.status === 'ready' | 0.67 s | Report ready |
A fixed delay works, but you pay the full second on every request even when the page is ready sooner. A wait expression stops as soon as the condition is true:
curl -s -o dashboard.pdf \
--form "[email protected];filename=index.html" \
--form "waitForExpression=window.status === 'ready'" \
http://localhost:3000/forms/chromium/convert/html
Confirm the late text made it in:
pdftotext dashboard.pdf - | grep -E "Loading|Report ready"
Only the final status is there:
Report ready
In your own templates, set the flag once rendering finishes. There’s also waitForSelector, which suits single page apps that append an element once data has loaded.
5. Page numbers with header and footer templates
Gotenberg passes header.html and footer.html straight to Chromium’s print templates. Chromium fills elements carrying the classes pageNumber, totalPages, title, date and url. These templates render in a separate context: your page CSS does not apply, and they cannot load external fonts or images, so keep the styling inline. Create the footer first:
vim footer.html
This one prints “Page X of Y” centred at the bottom:
<html>
<head>
<style>
html { font-size: 10px; margin: 0 40px; }
p { width: 100%; text-align: center; color: #555; font-family: sans-serif; }
</style>
</head>
<body>
<p>Page <span class="pageNumber"></span> of <span class="totalPages"></span></p>
</body>
</html>
Then the header:
vim header.html
It pulls the document title from the page’s <title> tag:
<html>
<head>
<style>
html { font-size: 10px; margin: 0 40px; -webkit-print-color-adjust: exact; }
p { width: 100%; text-align: right; color: #555; font-family: sans-serif; }
</style>
</head>
<body>
<p>ACME Hosting Ltd · <span class="title"></span></p>
</body>
</html>
Chromium only draws the templates inside the page margins, so leave room for them with marginTop and marginBottom. Skip that and the header renders on top of your content, or not at all.
Convert Markdown with the same templates
The Markdown route takes an HTML wrapper plus one or more .md files, and you mark where the rendered Markdown goes with a Go template action. Create the wrapper:
vim runbook-index.html
The {{ toHTML "runbook.md" }} line is where Gotenberg injects the converted Markdown:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Runbook</title>
<style>
body { font-family: sans-serif; font-size: 11pt; margin: 0 1cm; }
code, pre { background: #f2f2f2; font-family: monospace; }
table { border-collapse: collapse; }
td, th { border: 1px solid #999; padding: 4px 8px; }
</style>
</head>
<body>
{{ toHTML "runbook.md" }}
</body>
</html>
Now the Markdown itself:
vim runbook.md
A short runbook with a list, a table and a code block is enough to see how the conversion handles each:
# Restore the billing database
1. Stop the API workers.
2. Restore the latest snapshot.
3. Run the smoke tests.
| Step | Owner | Target time |
|------|-------|-------------|
| Stop workers | platform | 2 min |
| Restore | dba | 15 min |
| Smoke tests | qa | 5 min |
```
pg_restore -d billing /backups/billing-latest.dump
```
Send all four files to the Markdown route with A4 paper and one inch top and bottom margins. The wrapper goes up as index.html:
curl -s -o runbook.pdf \
--form "[email protected];filename=index.html" \
--form [email protected] \
--form [email protected] \
--form [email protected] \
--form paperWidth=8.27 --form paperHeight=11.7 \
--form marginTop=1 --form marginBottom=1 \
http://localhost:3000/forms/chromium/convert/markdown
The request took 0.27 seconds. Dump the text layer to check the header, the rendered table and the footer counter:
pdftotext -layout runbook.pdf - | grep -v '^\s*$'
The header carries the page title and the footer counts pages:
ACME Hosting Ltd · Runbook
Restore the billing database
1. Stop the API workers.
2. Restore the latest snapshot.
3. Run the smoke tests.
Step Owner Target time
Stop workers platform 2 min
Restore dba 15 min
Smoke tests qa 5 min
pg_restore -d billing /backups/billing-latest.dump
Page 1 of 1
Paper and margin fields default to inches, but units work too. I tried marginTop=20mm, 2cm and 0.79in, and all three put the first line at the same position on the page.
6. Convert URLs and Office documents, then merge
The URL route renders a live page. Only the target URL goes in the form, and the other Chromium options from the HTML route still apply:
curl -s -o example.pdf \
--form url=https://example.com \
--form printBackground=true \
http://localhost:3000/forms/chromium/convert/url
Office files go to the LibreOffice route. Copy any Word document into the working directory as change-request.docx (mine was a one-page change request with a table); .odt, .xlsx and .pptx files work the same way:
curl -s -o change-request.pdf \
--form [email protected] \
http://localhost:3000/forms/libreoffice/convert
The PDF came back in 1.31 seconds. Check which engine produced it:
pdfinfo change-request.pdf | grep -E 'Pages|Producer'
LibreOffice inside the container did the work:
Producer: LibreOffice 26.8.0.3 (X86_64)
Pages: 1
Merging is a PDF engine route, so it never starts a browser. Files are merged in the order you send them:
curl -s -o merged.pdf \
--form [email protected] \
--form [email protected] \
http://localhost:3000/forms/pdfengines/merge
That took 24 ms. Set document metadata on the result with the metadata route, which takes a JSON object:
curl -s -o statements.pdf \
--form [email protected] \
--form 'metadata={"Author":"Billing Team","Title":"October statements","Keywords":["invoice","runbook"]}' \
http://localhost:3000/forms/pdfengines/metadata/write
Read the fields back to confirm they stuck:
pdfinfo statements.pdf | grep -E 'Title|Author|Keywords|Pages'
All three fields carry the new values, and both source documents are there:
Title: October statements
Keywords: invoice, runbook
Author: Billing Team
Pages: 2
A small detail from earlier tests: when your HTML has no <title>, Chromium uses the temporary upload filename as the PDF title, which shows up as a random UUID in the viewer’s title bar. Give every template a title.
7. PDF/A, tagged PDFs and the web font trap
Archival and accessibility requirements show up sooner or later with invoices. Gotenberg exposes three fields for them, and they do not behave the same way. This is what happened when each was added to the invoice request that uploads Lobster as a web font:
| Field | Time | Producer | Lobster heading kept? |
|---|---|---|---|
pdfa=PDF/A-3b | 0.65 s | LibreOffice | No |
pdfua=true | 0.44 s | LibreOffice | No |
generateTaggedPdf=true | 0.25 s | Skia/PDF (Chromium) | Yes |
Both pdfa and pdfua run the Chromium output through LibreOffice. Chromium embedded our uploaded Lobster face as a Type 3 font, and that conversion dropped it, so the “Invoice” heading came out in a plain system face. generateTaggedPdf is Chromium-native, keeps the font and still produces a tagged PDF, so use it when you need accessibility tags but not a PDF/A or PDF/UA conformance claim. If your templates never run JavaScript, WeasyPrint is worth a look for this job, since it writes PDF/A natively without a second engine in the path.
When you do need PDF/A from Gotenberg with your own typeface, install the font into the image instead of uploading it. The quick test container from section 2 holds port 3000, so remove it first:
docker rm -f gotenberg
Open a Dockerfile in the working directory, next to the font you downloaded in section 1:
vim Dockerfile
The image runs as a non-root gotenberg user (UID 1001), so switch to root for the copy and back afterwards:
FROM gotenberg/gotenberg:8
USER root
COPY Lobster-Regular.ttf /usr/local/share/fonts/
USER gotenberg
Build it:
docker build -t gotenberg-fonts:8 .
Then run it in place of the stock image:
docker run -d --name gotenberg-fonts --restart unless-stopped -p 127.0.0.1:3000:3000 gotenberg-fonts:8
The font now lives inside the container, so the HTML should use it by family name only. Make a copy of the invoice without the @font-face rule:
sed '/@font-face/d' invoice.html > invoice-pdfa.html
Request PDF/A again, this time without uploading the font:
curl -s -o invoice-pdfa.pdf \
--form "[email protected];filename=index.html" \
--form preferCssPageSize=true \
--form printBackground=true \
--form pdfa=PDF/A-3b \
http://localhost:3000/forms/chromium/convert/html
It took 1.66 seconds (including the lazy Chromium start). Check the fonts:
pdffonts invoice-pdfa.pdf
Lobster survives the PDF/A pass this time, as a regular TrueType face:
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
BAAAAA+LiberationSerif TrueType WinAnsi yes yes yes 65 0
CAAAAA+Lobster-Regular TrueType WinAnsi yes yes yes 60 0
DAAAAA+LiberationSans TrueType WinAnsi yes yes yes 70 0
EAAAAA+LiberationSans-Bold TrueType WinAnsi yes yes yes 75 0
The XMP metadata carries the PDF/A identification that validators look for:
pdfinfo -meta invoice-pdfa.pdf | grep -oE '<pdfaid:[a-z]+>[^<]+'
Part 3, conformance level B:
<pdfaid:part>3
<pdfaid:conformance>B
If this font image is the one you want in production, set image: gotenberg-fonts:8 in the Compose file in the next section instead of the stock image. Either way, remove the test container before moving on, because Compose binds the same port:
docker rm -f gotenberg-fonts
8. Run Gotenberg with Docker Compose
For anything beyond testing, I run Gotenberg from Compose with the hardening flags baked in. If Compose isn’t installed yet, the Docker Compose setup guide covers it. Create the project directory:
mkdir -p ~/gotenberg && cd ~/gotenberg
Credentials for the API’s basic auth go in an .env file that Compose reads automatically:
vim .env
Pick your own username and a long random password:
GOTENBERG_USER=pdfapi
GOTENBERG_PASSWORD=Ch4nge-This-Passw0rd
Lock the file down so only your user can read it:
chmod 600 .env
Now the Compose file:
vim compose.yaml
This is the exact file from the lab. --chromium-auto-start fixes the lazy-start delay from section 3, --log-std-format=json writes one JSON object per log line for your log shipper, and the remaining flags are explained in the production hardening section at the end:
services:
gotenberg:
image: gotenberg/gotenberg:8
container_name: gotenberg
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
command:
- "gotenberg"
- "--api-enable-basic-auth=true"
- "--api-timeout=60s"
- "--api-body-limit=50MB"
- "--chromium-auto-start=true"
- "--chromium-restart-after=100"
- "--chromium-max-concurrency=4"
- "--chromium-max-queue-size=20"
- "--chromium-deny-private-ips=true"
- "--libreoffice-restart-after=10"
- "--log-std-format=json"
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_USER}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_PASSWORD}
read_only: true
tmpfs:
- /tmp
- /home/gotenberg
deploy:
resources:
limits:
cpus: "2"
memory: 1536M
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
Flags go in command, not entrypoint: the image’s entrypoint is tini, which starts Gotenberg as a child process, and the Gotenberg docs warn against overriding it. The port is bound to 127.0.0.1, so only the host (or a reverse proxy on it) can reach the API. Validate the file and start the stack:
docker compose config -q && docker compose up -d
Give the health check a few seconds, then look at the status:
docker compose ps
The container reports healthy, even with a read-only root filesystem:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
gotenberg gotenberg/gotenberg:8 "/usr/bin/tini -- go…" gotenberg 6 seconds ago Up 5 seconds (healthy) 127.0.0.1:3000->3000/tcp
The startup log lists every module and confirms Chromium launched at boot rather than on the first request:
docker compose logs --no-log-prefix | grep -E 'Version|modules|automatically|server started'
These are the lines to look for:
Version: 8.37.0
[SYSTEM] modules: api chromium exiftool libreoffice libreoffice-api libreoffice-pdfengine pdfcpu pdfengines pdftk prometheus qpdf webhook
[SYSTEM] chromium: Chromium automatically started
[SYSTEM] api: server started on [::]:3000
Every request now needs credentials. Go back to the working directory and load them into your shell from the same .env file, so the examples below run unchanged:
cd ~/pdf-api && set -a && . ~/gotenberg/.env && set +a
Repeat the invoice conversion with -u:
curl -s -u "${GOTENBERG_USER}:${GOTENBERG_PASSWORD}" -o invoice.pdf \
--form "[email protected];filename=index.html" --form [email protected] \
--form preferCssPageSize=true --form printBackground=true \
http://localhost:3000/forms/chromium/convert/html
That was the first request against the new container and it came back in 0.23 seconds, because Chromium was already running. Without credentials the same request gets HTTP 401.
The container runs with a read-only root filesystem, so I pushed the two heaviest paths through it as well. The DOCX from section 6 converted in 0.70 seconds and a pdfa=PDF/A-3b request in 0.41 seconds, both returning 200, and docker logs gotenberg showed no read-only filesystem errors. After those renders docker stats reported 250 MiB in use against the 1.5 GiB limit.
9. Call the API from Python and Node.js
Most of the time Gotenberg sits behind application code, not curl. Both clients below read the same GOTENBERG_USER and GOTENBERG_PASSWORD variables, upload the HTML as index.html whatever its local name, and add any assets by basename so the flat-directory rule from section 3 holds.
Python with requests
Install requests into a virtual environment if your project doesn’t already have it:
python3 -m venv ~/venv && ~/venv/bin/pip install requests
Then create the client:
vim html_to_pdf.py
The script takes the HTML file, any assets, and the output path as the last argument:
import os
import sys
import requests
GOTENBERG_URL = os.environ.get("GOTENBERG_URL", "http://localhost:3000")
AUTH = (os.environ["GOTENBERG_USER"], os.environ["GOTENBERG_PASSWORD"])
def html_to_pdf(html_path, assets, out_path):
files = [("files", ("index.html", open(html_path, "rb"), "text/html"))]
for asset in assets:
files.append(("files", (os.path.basename(asset), open(asset, "rb"))))
data = {"preferCssPageSize": "true", "printBackground": "true"}
r = requests.post(
f"{GOTENBERG_URL}/forms/chromium/convert/html",
files=files,
data=data,
auth=AUTH,
headers={"Gotenberg-Output-Filename": "invoice"},
timeout=90,
)
r.raise_for_status()
with open(out_path, "wb") as f:
f.write(r.content)
print(f"{out_path}: {len(r.content)} bytes, trace {r.headers.get('Gotenberg-Trace')}")
if __name__ == "__main__":
html_to_pdf(sys.argv[1], sys.argv[2:-1], sys.argv[-1])
Run it against the invoice:
~/venv/bin/python html_to_pdf.py invoice.html Lobster-Regular.ttf invoice-py.pdf
The Gotenberg-Trace header is the request ID that also appears in the container log, which saves time when you are chasing a failed render:
invoice-py.pdf: 50396 bytes, trace 97c92fc2-ecba-4b02-b2db-feb16e20c975
Node.js with fetch
Node 18 and newer ship fetch, FormData and Blob, so no packages are needed. If Node is not installed yet, see the prerequisites in section 1. Create the client:
vim html-to-pdf.mjs
Same arguments as the Python version:
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
const url = process.env.GOTENBERG_URL ?? 'http://localhost:3000';
const auth = Buffer.from(`${process.env.GOTENBERG_USER}:${process.env.GOTENBERG_PASSWORD}`).toString('base64');
const [htmlPath, ...rest] = process.argv.slice(2);
const outPath = rest.pop();
const form = new FormData();
form.append('files', new Blob([await readFile(htmlPath)], { type: 'text/html' }), 'index.html');
for (const asset of rest) {
form.append('files', new Blob([await readFile(asset)]), basename(asset));
}
form.append('preferCssPageSize', 'true');
form.append('printBackground', 'true');
const res = await fetch(`${url}/forms/chromium/convert/html`, {
method: 'POST',
headers: { Authorization: `Basic ${auth}` },
body: form,
});
if (!res.ok) throw new Error(`Gotenberg returned ${res.status}: ${await res.text()}`);
const pdf = Buffer.from(await res.arrayBuffer());
await writeFile(outPath, pdf);
console.log(`${outPath}: ${pdf.length} bytes, trace ${res.headers.get('gotenberg-trace')}`);
Run it the same way:
node html-to-pdf.mjs invoice.html Lobster-Regular.ttf invoice-node.pdf
Both clients produced a PDF of the same size:
invoice-node.pdf: 50396 bytes, trace 40792f33-8464-4d28-bd50-163812d80712
Check that the web font travelled with the request from both clients:
pdffonts invoice-py.pdf | grep -c Lobster; pdffonts invoice-node.pdf | grep -c Lobster
Each PDF carries the two Lobster entries seen in section 3:
2
2
10. Rootless Podman on Rocky Linux with SELinux
On Rocky Linux, AlmaLinux and RHEL you do not need Docker at all. Podman pulls the same image and runs it without root, and SELinux stays enforcing. Install Podman if it’s missing:
sudo dnf -y install podman
If the Compose stack from section 8 is running on this host, stop it first:
cd ~/gotenberg && docker compose down
Then run Gotenberg as your regular user, with the fully qualified image name:
podman run -d --name gotenberg -p 127.0.0.1:3000:3000 docker.io/gotenberg/gotenberg:8
The invoice request from section 3 rendered in 2.11 seconds on the first call. Check how the process is confined:
ps -o user,label,comm -C gotenberg
The process runs under a mapped UID from your subordinate range, labelled container_t with a unique MCS category pair:
USER LABEL COMMAND
918504 system_u:system_r:container_t:s0:c6,c151 gotenberg
Confirm SELinux did not block anything along the way:
sudo ausearch -m avc -ts recent
There were no denials during the run:
<no matches>
No booleans or file contexts needed changing, because the container never mounts a host path. Docker on the same Rocky box rendered the same invoice. The one snag was installing Docker itself: the get.docker.com script failed with Unable to find a match: docker-ce, and adding Docker’s RHEL repository by hand (as in our Rocky Docker guide) fixed it.
Across the four systems, the first conversion after a cold container start looked like this. Every one produced the same invoice with the Lobster font embedded:
| OS | Runtime | First request |
|---|---|---|
| Ubuntu 26.04 LTS | Docker CE | 0.79 s |
| Ubuntu 24.04 LTS | Docker CE | 0.57 s |
| Debian 13 | Docker CE | 0.97 s |
| Rocky Linux 10, SELinux enforcing | Docker CE | 2.60 s |
| Rocky Linux 10, SELinux enforcing | Podman, rootless | 2.11 s |
Warm requests on the same boxes sat around 0.2 seconds, so the spread is Chromium starting lazily on that first call, not the distribution. Set --chromium-auto-start=true and it goes away.
Coming from wkhtmltopdf
Most wkhtmltopdf options map to a form field. The big difference is that page size, margins and headers now usually live in your CSS, and Gotenberg only needs preferCssPageSize=true to respect them.
| wkhtmltopdf option | Gotenberg equivalent |
|---|---|
--page-size A4 | CSS @page { size: A4 } with preferCssPageSize=true, or paperWidth=8.27 and paperHeight=11.7 |
-O Landscape | landscape=true |
--margin-top 20mm | marginTop=20mm |
--header-html, --footer-html | header.html and footer.html files, or CSS margin boxes |
[page] and [topage] placeholders | pageNumber and totalPages classes |
--javascript-delay 1000 | waitDelay=1s, better waitForExpression |
--print-media-type | Default (emulatedMediaType=print) |
--no-background | Default. Send printBackground=true to keep backgrounds |
--zoom 1.2 | scale=1.2 |
--cookie, --custom-header | cookies and extraHttpHeaders JSON fields |
--enable-local-file-access | No equivalent. Upload assets with the request |
--disable-javascript | Server flag --chromium-disable-javascript |
Watch the background default. wkhtmltopdf prints backgrounds unless told not to, and Chromium drops them unless told to keep them. If your invoice table headers suddenly print white, printBackground=true is the fix. For the rest of the migration (Odoo, ERPNext and the other apps still bundling wkhtmltopdf), see our notes on installing wkhtmltopdf on Ubuntu and Debian, which covers what still works and what doesn’t.
Production hardening
The Compose file in section 8 already carries these settings. This is what each one does, along with the response I got when I tried to break it.
Basic auth. --api-enable-basic-auth=true reads the username and password from GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD. A conversion without credentials gets HTTP 401, and so does /prometheus/metrics. /health stays open (it returned 200 without credentials), which keeps load balancer and Compose health checks simple. For anything exposed beyond the host, put Gotenberg behind a reverse proxy with TLS, or use the built-in --api-tls-cert-file and --api-tls-key-file flags. If you already run an identity provider, --api-enable-oidc-auth validates bearer tokens instead, and you can’t enable both.
Outbound filtering. This is the SSRF control, and it matters if users can influence the HTML or URLs you send. --chromium-deny-private-ips=true refuses any navigation or sub-resource whose host resolves to loopback, RFC1918, link-local or CGNAT space. Asking it to render http://10.0.0.1/ returned HTTP 403 Forbidden, and so did the host’s docker0 address 172.17.0.1, which is how a container usually reaches services on its host. For a stricter setup, pin Chromium to the hosts your templates actually use with an allow list. On a test container started with --chromium-allow-list=^https://example\.com/.*, https://example.com/ rendered and https://www.iana.org/ returned 403. Two caveats from the docs: a URL that matches the allow list skips the private and public IP checks entirely, so an allow entry for an internal host deliberately opens that host. Anchor every pattern with ^ and end the host with a / or $, otherwise example\.com also matches example.com.attacker.net. The default --chromium-deny-list value, ^file:(?!//\/tmp/).*, is the rule that blocked file:///etc/passwd earlier. If you override the deny list, keep that pattern in it. The same allow, deny and private-IP flags exist for LibreOffice, the webhook module and the downloadFrom feature.
Timeouts. --api-timeout caps each request (30 seconds by default). On a test container with a 5 second limit, a page asking for waitDelay=8s came back after exactly 5.0 seconds with HTTP 503 and this body:
The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload.
Queue and concurrency. Chromium handles up to six conversions at once (--chromium-max-concurrency, default 6). Extra requests wait in an unbounded queue unless you set --chromium-max-queue-size. With concurrency 1 and a queue size of 1, I fired four 2-second requests at once. One rendered and three were rejected within 6 ms with HTTP 429:
The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size.
A fast 429 is what you want under load. Your client can retry with backoff instead of holding connections open until everything times out. Size the queue from your timeout: if a typical render takes 0.3 seconds and the API timeout is 60 seconds, a queue of 20 per instance drains well inside the limit.
Restarts. --chromium-restart-after=100 recycles the browser every 100 conversions and --libreoffice-restart-after=10 recycles LibreOffice every 10. Both are the defaults, and I keep them explicit in the file so nobody “optimises” them to 0, which disables recycling and lets a long-running engine grow. The Prometheus endpoint exposes the counters if you want to watch it happen (gotenberg_chromium_restarts_count, gotenberg_chromium_requests_queue_size), though the project has deprecated the Prometheus module in favour of OpenTelemetry, so plan new dashboards on OTel.
Container limits. read_only: true with tmpfs mounts on /tmp and /home/gotenberg handled the HTML, DOCX and PDF/A requests in section 8 without a single write error, and nothing a template writes survives a container restart. The 1,536 MB memory limit leaves room above the 310 MB render peak we measured for burst concurrency. Raise it if you lift --chromium-max-concurrency or convert large spreadsheets. --api-body-limit=50MB stops someone from uploading a 2 GB “asset” and filling the tmpfs.
Scale out, not up. Gotenberg holds no state between requests, so two or three replicas behind a load balancer beat one container with a huge concurrency setting. A crashed browser then costs you a third of capacity instead of all of it. The /health route is ready to use as the balancer probe.