Why self-host a PDF generation API?
SaaS PDF services (Puppeteer Cloud, PDFShift, DocRaptor) charge per document, per page or by API call volume. As soon as your application generates invoices, reports or contracts at scale, the cost grows linearly with your usage. Gotenberg replaces them with a fixed monthly VPS cost, regardless of how many PDFs you generate. It also keeps your documents private: nothing leaves your infrastructure during conversion.
Gotenberg, WeasyPrint, Stirling-PDF or Puppeteer?
Scroll the table
| Tool | Source formats | Min RAM | REST API | Licence |
|---|---|---|---|---|
| Gotenberg 8.x | HTML, URL, DOCX/ODT/XLSX, Markdown, images | 2 GB (Chromium) | Yes — multipart/form-data | MIT |
| WeasyPrint | HTML + CSS (no JS) | 256 MB | No — Python lib/CLI | BSD |
| Stirling-PDF | PDF (edit, OCR, merge) | 512 MB | Yes — JSON/multipart | MIT (AGPLv3 for Pro) |
| Puppeteer alone | HTML + URL via headless Chrome | 1.5 GB | No — Node.js lib | Apache 2.0 |
What Gotenberg can convert
- HTML to PDF via a full Chromium instance — CSS, web fonts, JavaScript and print media queries are all honoured.
- Any URL to PDF — archive dashboard views, generate weekly reports or capture payment receipts.
- LibreOffice documents (DOCX, XLSX, PPTX, ODT, ODS, ODP and 20+ formats) to PDF without installing Microsoft Office.
- Markdown to PDF via the Chromium route with configurable CSS.
- Multiple PDFs merged into a single file in one API call.
- Images (PNG, JPEG, WebP, TIFF) converted to PDF.
Requirements
A VPS with at least 1 vCPU and 2 GB RAM (Chromium alone consumes 1.2–1.5 GB during rendering; 1 GB RAM is insufficient as soon as a conversion is in progress). Ubuntu 22.04 or 24.04 with Docker and Docker Compose v2 installed. Gotenberg listens on port 3000 by default. No domain is needed if you call the API from the same server — http://127.0.0.1:3000 is enough. To expose it to other services, an nginx reverse proxy is recommended (see below).
Deploy Gotenberg step by step
Create the compose file
SSH into your VPS and create a directory:
mkdir gotenberg && cd gotenbergCreate
docker-compose.yml:services: gotenberg: image: gotenberg/gotenberg:8 restart: unless-stopped ports: - "127.0.0.1:3000:3000" command: - gotenberg - --chromium-disable-javascript=false - --api-timeout=30s - --chromium-max-queue-size=10Binding to
127.0.0.1prevents direct access from the internet. As of 2026, the stable release is 8.x (8.9.1 and above) — the:8image tracks the latest 8.x.Start the container
Run
docker compose up -d. Gotenberg pulls the image (~1.5 GB, includes Chromium and LibreOffice) and starts the HTTP server. Check the health endpoint:curl http://127.0.0.1:3000/healthA
{"status":"up"}response confirms the service is ready.Generate your first PDF from HTML
Create a minimal HTML file (
invoice.html) and convert it:curl -s --request POST http://127.0.0.1:3000/forms/chromium/convert/html \ --form '[email protected]' \ -o invoice.pdfThe response is the PDF as a binary stream — pipe it to a file, a response object, or an S3 upload.
Convert a Word document to PDF
Upload a DOCX file to the LibreOffice route:
curl -s --request POST http://127.0.0.1:3000/forms/libreoffice/convert \ --form '[email protected]' \ -o contract.pdfGotenberg uses the embedded LibreOffice engine for conversion — no Microsoft Office licence required.
Add authentication (recommended)
Gotenberg has no built-in auth. The recommended approach: install nginx, create a reverse proxy from
https://gotenberg.your-domain.comtohttp://127.0.0.1:3000, and add HTTP basic auth. Only your application's backend should call this endpoint — never expose it unauthenticated to the public internet, as it can be abused to render arbitrary URLs.
API routes and formats
Gotenberg exposes two families of REST routes, all POST multipart/form-data.
Chromium routes — headless browser rendering:
- POST /forms/chromium/convert/html — conversion from a local HTML file (field index.html); CSS/JS/image assets can be attached as additional fields.
- POST /forms/chromium/convert/url — conversion from a public URL (field url); Chromium loads the full page, JavaScript included.
- POST /forms/chromium/convert/markdown — Markdown conversion with optional stylesheet.
LibreOffice routes — office document conversion:
- POST /forms/libreoffice/convert — accepts DOCX, XLSX, PPTX, ODT, ODS, ODP and 20+ formats; multiple files merged into one PDF if you send merge=true.
PDF routes — existing PDF manipulation:
- POST /forms/pdfengines/merge — merges multiple PDF files into one.
- POST /forms/pdfengines/convert — converts to PDF/A (long-term archiving).
Webhook: instead of receiving the PDF as a synchronous response, pass a Gotenberg-Webhook-Url header — Gotenberg then POSTs the PDF to your URL as soon as the conversion is done, freeing the client connection immediately. Useful for long conversions or asynchronous architectures.
nginx reverse proxy
To expose Gotenberg to other services or call it from a dedicated domain, add a location block to your nginx configuration:
server {
listen 443 ssl;
server_name gotenberg.your-domain.com;
ssl_certificate /etc/letsencrypt/live/gotenberg.your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gotenberg.your-domain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 60s;
client_max_body_size 50M;
auth_basic "Gotenberg API";
auth_basic_user_file /etc/nginx/.htpasswd;
}
}If Gotenberg runs on the same server as your backend, prefer a direct call to http://127.0.0.1:3000 — less latency and no need to expose the service on a domain.
Advanced integration: PHP, Python, Node.js
Gotenberg is language-agnostic — any HTTP client capable of sending multipart/form-data works.
PHP / Laravel — use the official gotenberg-php package:
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
$request = Gotenberg::chromium('http://127.0.0.1:3000')
->pdf()
->html(Stream::path('/path/to/invoice.html'));
$response = $client->sendRequest($request);
file_put_contents('invoice.pdf', $response->getBody());Python — with the gotenberg-client library:
from gotenberg_client import GotenbergClient
with GotenbergClient('http://127.0.0.1:3000') as client:
with client.chromium.html_to_pdf() as route:
route.index(Path('invoice.html'))
response = route.run()
Path('invoice.pdf').write_bytes(response.content)Node.js — with native fetch (Node 18+):
const form = new FormData();
form.append('index.html', new Blob([htmlContent], { type: 'text/html' }), 'index.html');
const res = await fetch('http://127.0.0.1:3000/forms/chromium/convert/html', {
method: 'POST',
body: form,
});
const pdf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync('invoice.pdf', pdf);For Node.js, the chromiumly package wraps the full API if you prefer to avoid manual FormData handling.
Advanced configuration
Parameters are passed as arguments to the gotenberg command in your docker-compose.yml (or via GOTENBERG_-prefixed environment variables).
Timeout: --api-timeout=30s — maximum duration of a conversion. Increase it (e.g. 60s) for long DOCX documents or pages with heavy JavaScript.
Concurrency: --chromium-max-queue-size=10 — maximum number of Chromium conversions in queue. Beyond this, Gotenberg responds 503 rather than accumulating in memory. Adjust based on available RAM (each Chromium conversion opens an ephemeral instance).
Rate limiting: --api-port-from-env combined with an nginx reverse proxy (limit_req_zone) is the recommended approach to throttle incoming calls.
Logs: Gotenberg logs in JSON to stdout — docker compose logs gotenberg gives you the detail of each conversion (duration, route, status). For more verbose output:
command:
- gotenberg
- --log-level=debugDisable LibreOffice (if you only need Chromium, saves RAM):
command:
- gotenberg
- --libreoffice-disable-listener=trueFor high-volume architectures, run multiple Gotenberg replicas behind an nginx load balancer (upstream): each instance holds its own Chromium workers, and you scale horizontally without shared state.
Troubleshooting common errors
Chromium timeout — context deadline exceeded: the HTML conversion exceeds the configured timeout. Common causes: page with slow JavaScript, external resources (CDN, Google Fonts) unreachable from the server, or oversized DOM. Fix: increase --api-timeout, host your assets locally and attach them as multipart fields rather than loading them via URL.
Port 3000 already in use: another application is listening on 3000. Change the mapping in docker-compose.yml: "127.0.0.1:3001:3000" and update the URL in your calls.
File too large — 413 Request Entity Too Large: nginx cuts the request before Gotenberg. Increase client_max_body_size in the nginx block (e.g. 100M for large XLSX files).
Out of memory — OOMKilled: Docker kills the container when Chromium exceeds available RAM. Check with docker inspect gotenberg | grep OOMKilled. Fixes: add RAM to the VPS, limit concurrency (--chromium-max-queue-size=3), or disable JavaScript if your HTML does not require it (--chromium-disable-javascript=true).
LibreOffice error — corrupted file or unrecognised format: some DOCX files generated by third-party tools do not conform to the OOXML standard. First verify the file opens without errors in LibreOffice Desktop. The Gotenberg log header gives the exact LibreOffice message (docker compose logs gotenberg --tail 50).
Integrate with your application
Gotenberg is language-agnostic — any HTTP client works. Libraries exist for PHP (gotenberg-php), Node.js (chromiumly), Python (gotenberg-client), Go and others. For Laravel, a first-party package wraps the API. In Node.js, you can call it with axios or the native fetch API by POSTing a FormData object with the HTML content or file. See the advanced integration section above for ready-to-use code examples in each language.