Ein Angebot, eine Rechnung und ein Report brauchen alle irgendwann ein PDF, und in einer Agentur mit mehreren laufenden Kundenprojekten kommt diese Anforderung nicht einmal, sondern in jedem einzelnen Projekt wieder vor. Die naheliegende Lösung heißt Puppeteer, eine Node.js-Bibliothek, die einen echten, aber unsichtbaren Chrome-Browser fernsteuert und eine Webseite als PDF ausdruckt. Das Problem entsteht, sobald mehr als ein Projekt diese Lösung braucht: Jedes Repository bekommt seine eigene Puppeteer-Version, sein eigenes Chromium-Programm von mehreren hundert Megabyte im Docker-Image, und seinen eigenen Umgang mit dem bekannten Speicherleck, das lange laufende Chromium-Prozesse mit der Zeit erzeugen. Die Alternative ist eine SaaS-PDF-API, ein extern gehosteter Dienst, der PDFs gegen ein Entgelt pro Dokument erzeugt und die komplette Infrastruktur dafür selbst betreibt. Damit entfällt die Wartung, aber die Rechnung wächst linear mit der Zahl der Kundenprojekte, die den Dienst nutzen.
Gotenberg schließt genau diese Lücke zwischen Eigenbau und SaaS. Der Dienst ist quelloffen, läuft komplett auf eigener Infrastruktur und liegt aktuell in Version 8.36.0 vor, ausgeliefert als Docker-Image gotenberg/gotenberg:8. Er bündelt headless Chromium, einen Browser ohne sichtbare Oberfläche, für HTML- und URL-zu-PDF-Konvertierung, dazu LibreOffice für Word-, Excel- und PowerPoint-Dateien sowie die PDF-Werkzeuge qpdf, pdfcpu und PDFtk für das Zusammenführen, Aufteilen und Verschlüsseln fertiger PDFs. Alles läuft hinter einer einzigen HTTP-Schnittstelle, die per POST-Aufruf mit multipart/form-data angesprochen wird, dem Standardformat für Anfragen, die Dateien und einzelne Formularfelder gemeinsam übertragen. Der Dienst selbst merkt sich nichts zwischen zwei Aufrufen: Jede Konvertierung startet frisch und liefert ihr Ergebnis direkt in der Antwort zurück.
Für ein einzelnes Projekt mit gelegentlichem PDF-Bedarf lohnt sich ein eigener Gotenberg-Server oft nicht. Ein zusätzlicher Container will überwacht, aktualisiert und im Fehlerfall neu gestartet werden, und dafür fehlt in einem Ein-Projekt-Setup meist schlicht der Anlass. Sobald aber mehrere Kundenprojekte denselben Bedarf haben, kippt die Rechnung deutlich. Ein gemeinsamer Gotenberg-Container läuft dann einmal auf einem zentralen Server, und jedes Projekt ruft ihn über eine interne Adresse auf, ohne selbst eine Zeile Puppeteer-Code oder ein zusätzliches Chromium-Programm im eigenen Image mitzuschleppen.
Systemvoraussetzungen
Ein gemeinsamer Gotenberg-Dienst setzt wenig voraus, weil die gesamte benötigte Software in einem einzigen Docker-Image mitgeliefert wird.
| Komponente | Anforderung |
|---|---|
| Gotenberg | Version 8.36.0, Docker-Image gotenberg/gotenberg:8, enthält Chromium, LibreOffice sowie die PDF-Werkzeuge qpdf, pdfcpu und PDFtk |
| Schlankere Variante | gotenberg/gotenberg:8-chromium für reine HTML- und URL-Konvertierung ohne LibreOffice, rund 30 Prozent kleiner als das volle Image |
| Docker | Docker Engine mit Compose-Plugin (Docker Compose v2) auf dem gemeinsamen Server |
| Netzwerk | Internes Docker-Netzwerk zwischen dem Gotenberg-Container und jedem aufrufenden Projekt-Container, kein öffentlicher Zugriff nötig |
| Konvertierungsroute | POST /forms/chromium/convert/html, erwartet multipart/form-data mit einer Hauptdatei namens index.html |
| Zeitlimit je Anfrage | Parameter api-timeout, Standardwert 30 Sekunden, bei umfangreichen Reports oder Broschüren zu erhöhen |
Systemarchitektur
flowchart TD A[Projekt A schickt HTML per POST Anfrage] --> Q[Interne Warteschlange im Gotenberg Container] B[Projekt B schickt HTML per POST Anfrage] --> Q C[Projekt C schickt HTML per POST Anfrage] --> Q Q --> D[Bis zu 6 Chromium Konvertierungen laufen parallel] D --> E[Chromium rendert das HTML und wartet auf die Bedingung aus waitForExpression] E --> F[Gotenberg erzeugt das PDF mit Seitengroesse Raendern und Fusszeile] F --> G[PDF Antwort geht an das aufrufende Projekt zurueck] D --> R[Chromium startet nach 100 Konvertierungen automatisch neu] R --> D
Jedes Kundenprojekt schickt seine Anfrage unabhängig von den anderen an dieselbe Adresse, üblicherweise einen internen Docker-Hostnamen wie http://gotenberg:3000, den nur Container im selben Netzwerk erreichen. Gotenberg nimmt jede Anfrage einzeln entgegen und reiht sie in eine interne Warteschlange ein, aus der bis zu sechs Konvertierungen gleichzeitig laufen, der Standardwert des Parameters chromium-max-concurrency. Innerhalb dieser Grenze rendert Chromium jede HTML-Seite wie ein gewöhnlicher Browser: Es lädt CSS und Bilder, führt eingebettetes JavaScript aus und wartet, sofern konfiguriert, auf eine bestimmte Bedingung im Dokument, bevor der Druckvorgang beginnt. Aus dem gerenderten Ergebnis erzeugt Chromium anschließend das PDF nach den übergebenen Vorgaben für Seitengröße, Ränder sowie Kopf- und Fußzeile und schickt die fertige Datei als Antwort an genau das Projekt zurück, das die Anfrage gestellt hat. Damit der Speicherverbrauch, der sich über viele Konvertierungen in einem einzigen Chromium-Prozess ansammelt, nicht unbegrenzt wächst, startet Gotenberg die Chromium-Instanz standardmäßig nach 100 Konvertierungen automatisch neu, gesteuert über den Parameter chromium-restart-after.
Schritt-für-Schritt-Umsetzung
Vier Handgriffe bringen den gemeinsamen PDF-Dienst vom leeren Server bis zum ersten erzeugten Dokument aus einem Kundenprojekt:
- Gotenberg als Docker-Compose-Dienst auf dem gemeinsamen Server einrichten
- HTML-Dokument per REST-Aufruf an die Chromium-Route schicken
- Seitengröße, Ränder sowie Kopf- und Fußzeile für das Zieldokument festlegen
- Auf das Laden dynamischer Inhalte warten, bevor Gotenberg das PDF rendert
Gotenberg als Docker-Compose-Dienst einrichten
Der Dienst braucht keinen eigenen Server, sondern nur einen weiteren Eintrag in der bestehenden Docker-Compose-Datei des gemeinsam genutzten Hosts. Ein öffentlicher Port ist nicht nötig, weil ausschließlich Container im selben internen Netzwerk zugreifen:
services:
gotenberg:
image: gotenberg/gotenberg:8.36.0
container_name: gotenberg
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-max-concurrency=6"
- "--chromium-restart-after=50"
- "--chromium-max-queue-size=30"
- "--api-timeout=60s"
networks:
- internal
networks:
internal:
driver: bridge
Zwei Werte weichen hier bewusst vom Standard ab. Der Parameter chromium-restart-after steht auf 50 statt der Standardvorgabe 100, weil ein gemeinsam genutzter Container Speicher schneller ansammelt als ein Container für nur ein Projekt; jeder Neustart kostet dafür rund zwei bis drei Sekunden Chromium-Startzeit. Der Parameter chromium-max-queue-size steht auf 30 statt der Standardvorgabe 0. Der Standardwert 0 deaktiviert die Begrenzung vollständig, wodurch sich Anfragen bei Überlastung unbegrenzt in der Warteschlange stapeln, bis jede einzelne entweder verarbeitet wird oder das in api-timeout gesetzte Zeitlimit reißt. Eine gesetzte Obergrenze sorgt stattdessen dafür, dass neue Anfragen sofort abgelehnt werden, sobald die Warteschlange voll ist, statt dass ein aufrufendes Projekt minutenlang auf eine Antwort wartet, die ohnehin nicht mehr rechtzeitig kommt.
HTML-Dokument per REST-Aufruf an die Chromium-Route schicken
Die Route /forms/chromium/convert/html verlangt für das Hauptdokument zwingend den Dateinamen index.html, unabhängig davon, wie die Quelldatei im aufrufenden Projekt eigentlich heißt. Zusätzliche Ressourcen wie Bilder lassen sich als weitere Dateien im selben Feld files mitschicken und im HTML relativ referenzieren:
curl -X POST http://gotenberg:3000/forms/chromium/convert/html \
-F "files=@index.html" \
-F "files=@logo.png" \
-F "printBackground=true" \
-F "paperWidth=8.27" \
-F "paperHeight=11.69" \
-o angebot-2026-114.pdf
Aus einem Node.js- oder TypeScript-Projekt heraus baut sich derselbe Aufruf mit der eingebauten fetch-Funktion und einem FormData-Objekt zusammen:
const form = new FormData();
form.append(
"files",
new Blob([htmlString], { type: "text/html" }),
"index.html"
);
form.append("printBackground", "true");
form.append("paperWidth", "8.27");
form.append("paperHeight", "11.69");
const response = await fetch(
"http://gotenberg:3000/forms/chromium/convert/html",
{ method: "POST", body: form }
);
if (!response.ok) {
throw new Error("Gotenberg antwortete mit Status " + response.status);
}
const pdfBuffer = Buffer.from(await response.arrayBuffer());
Entscheidend ist der dritte Parameter beim append-Aufruf: Er setzt den Dateinamen des Blobs explizit auf index.html, auch wenn der HTML-String im Projekt selbst aus einer ganz anders benannten Vorlage stammt. Ohne diesen Parameter vergibt der Browser oder die Laufzeitumgebung einen anderen Namen, und Gotenberg beantwortet die Anfrage mit einem Fehler.
Seitengröße, Ränder sowie Kopf- und Fußzeile festlegen
Ohne eigene Angaben verwendet Gotenberg die Standardwerte für US-Letter, eine Papierbreite von 8,5 und eine Papierhöhe von 11 Zoll. Für ein deutsches Angebot oder einen Report in DIN A4 setzt paperWidth=8.27 und paperHeight=11.69 die passende Größe in Zoll, weil Gotenberg intern mit Zoll statt Zentimetern rechnet. Die Ränder marginTop, marginBottom, marginLeft und marginRight stehen standardmäßig auf 0,39 Zoll, umgerechnet ungefähr ein Zentimeter, und lassen sich einzeln überschreiben. Eine Kopf- oder Fußzeile kommt als eigene, vollständige HTML-Datei dazu, die isoliert von der Hauptseite gerendert wird und deshalb nur eingebettetes Inline-CSS verlässlich anzeigt:
<!-- footer.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-size: 9px; color: #666666; width: 100%; text-align: center; }
</style>
</head>
<body>
Seite <span class="pageNumber"></span> von <span class="totalPages"></span>
</body>
</html>
Die Klassen pageNumber und totalPages sind zwei von fünf reservierten Klassennamen, die Chromium automatisch mit Werten füllt; die übrigen heißen date, title und url. Der vollständige Aufruf mit Kopf- und Fußzeile sowie individuellen Rändern für einen mehrseitigen Report sieht dann so aus:
curl -X POST http://gotenberg:3000/forms/chromium/convert/html \
-F "files=@index.html" \
-F "files=@header.html" \
-F "files=@footer.html" \
-F "marginTop=1.0" \
-F "marginBottom=1.0" \
-F "marginLeft=0.39" \
-F "marginRight=0.39" \
-o report-q3.pdf
Auf das Laden dynamischer Inhalte warten, bevor Gotenberg das PDF rendert
Ein Report mit einem per JavaScript gezeichneten Diagramm braucht länger zum Aufbau als eine reine HTML-Seite, und Chromium weiß von sich aus nicht, wann dieser Aufbau abgeschlossen ist. Der Parameter waitForExpression löst dieses Problem, indem Gotenberg ein JavaScript-Ausdruck so lange wiederholt auswertet, bis er wahr zurückgibt, und erst dann den Druckvorgang startet. Die Seite selbst muss dafür an geeigneter Stelle eine globale Variable setzen, sobald sie fertig ist:
curl -X POST http://gotenberg:3000/forms/chromium/convert/html \
-F "files=@index.html" \
-F "waitForExpression=window.chartReady === true" \
-o report-mit-diagramm.pdf
Zwei Alternativen decken andere Fälle ab. Der Parameter waitForSelector wartet stattdessen darauf, dass ein bestimmtes Element über seinen CSS-Selektor im Dokument erscheint, was sich anbietet, wenn die Seite selbst keine eigene JavaScript-Variable setzen kann. Der Parameter waitDelay pausiert dagegen für eine feste Zeitspanne wie 2s, unabhängig davon, ob der Inhalt zu diesem Zeitpunkt wirklich fertig ist; er ist die am wenigsten zuverlässige der drei Optionen, weil er bei schnellen Seiten unnötig Zeit verschenkt und bei langsamen Servern trotzdem zu früh feuern kann.
Häufige Fehlerquellen
- 400 Bad Request bei jedem HTML-Aufruf: Die Hauptdatei heißt nicht exakt index.html. Gotenberg verlangt für die Chromium-HTML-Route genau diesen Namen für das Hauptdokument; jeder andere Dateiname führt zu einer Fehlerantwort im Klartext, die das betroffene Feld benennt, selbst wenn die Datei sonst korrekt hochgeladen wurde.
- Leere Kopf- oder Fußzeile trotz übergebener Datei: header.html oder footer.html verweisen auf eine externe CSS-Datei oder ein externes Bild. Beide Dateien rendern isoliert ohne Zugriff auf Ressourcen der Hauptseite, weshalb nur eingebettetes Inline-CSS zuverlässig ankommt.
- PDF enthält ein leeres Diagramm oder eine leere Tabelle: Die Seite lädt Daten per JavaScript nach, aber weder waitForExpression noch waitForSelector ist gesetzt. Chromium löst den Druckvorgang dann bereits aus, sobald das grundlegende DOM steht, oft bevor asynchron nachgeladene Inhalte überhaupt sichtbar sind.
- 503 Service Unavailable bei umfangreichen Reports: Die Konvertierung überschreitet das in api-timeout hinterlegte Zeitlimit von standardmäßig 30 Sekunden. Bei mehrseitigen Broschüren mit vielen Bildern hilft ein höherer Wert für api-timeout in der Compose-Konfiguration.
- Fehler -32000 bei sehr großen Dokumenten: Dieser bekannte Chromium-Fehler tritt bei besonders umfangreichen Seiten auf. Laut Gotenberg-Dokumentation hilft dagegen meist nur mehr Arbeitsspeicher für den Container, weil die Ursache in Chromium selbst liegt und nicht über eine Gotenberg-Einstellung behebbar ist.
- Alle Projekte spüren eine Verzögerung gleichzeitig: Mehr als sechs gleichzeitige Anfragen aus verschiedenen Kundenprojekten treffen auf denselben Container. Chromium bearbeitet standardmäßig nur sechs Konvertierungen parallel, der Rest wartet in der internen Warteschlange, bis Kapazität frei wird oder das Zeitlimit greift.
Häufig gestellte Fragen
Lohnt sich ein selbst gehosteter Gotenberg-Server auch für eine Agentur mit nur zwei oder drei Kundenprojekten?
Ein selbst gehosteter Gotenberg-Server lohnt sich bereits ab zwei bis drei Kundenprojekten, sobald jedes davon regelmäßig PDFs erzeugt, weil ein einzelner Container für alle Projekte gemeinsam ausreicht und dafür kein eigener großer Server nötig ist. Ein Cloud-Server mit 2 vCPU und 4 Gigabyte Arbeitsspeicher kostet bei Hetzner beispielsweise 3,79 Euro im Monat und trägt diese Last problemlos. Braucht dagegen nur ein einziges Projekt gelegentlich ein PDF, überwiegt meist der Aufwand für Betrieb und Updates den Nutzen, und eine SaaS-PDF-API ohne eigene Infrastruktur ist die einfachere Wahl.
Wie viel kostet eine SaaS-PDF-API im Vergleich zu einem selbst gehosteten Gotenberg-Server?
Eine SaaS-PDF-API wie DocRaptor kostet je nach gebuchtem Tarif zwischen etwa 3 und 12 US-Cent pro erzeugtem Dokument, während ein selbst gehosteter Gotenberg-Server auf einem Hetzner-Cloud-Server mit 2 vCPU und 4 Gigabyte Arbeitsspeicher pauschal 3,79 Euro im Monat kostet, unabhängig von der Dokumentenzahl. Bei 5.000 Dokumenten im Monat verlangt der passende DocRaptor-Tarif etwa 149 US-Dollar, während der Gotenberg-Server im selben Monat beim Festpreis von 3,79 Euro bleibt. Der Unterschied ist der Wartungsaufwand, den die SaaS-Variante abnimmt und den beim Eigenbetrieb stattdessen das eigene Team trägt.
Was passiert, wenn mehr als sechs PDF-Anfragen gleichzeitig bei Gotenberg eintreffen?
Bei mehr als sechs gleichzeitigen PDF-Anfragen reiht Gotenberg die überzähligen Anfragen in einer internen Warteschlange ein, weil der Parameter chromium-max-concurrency standardmäßig sechs parallele Chromium-Konvertierungen erlaubt. Ohne eine gesetzte Obergrenze für chromium-max-queue-size wächst diese Warteschlange unbegrenzt weiter, bis jede Anfrage entweder verarbeitet wird oder das in api-timeout hinterlegte Zeitlimit von standardmäßig 30 Sekunden überschreitet und mit einem Fehler beantwortet wird. Für einen gemeinsam genutzten Dienst empfiehlt sich deshalb eine gesetzte Obergrenze für die Warteschlange und, bei dauerhaft hoher Last, ein zweiter Gotenberg-Container.
Kann Gotenberg auch Word- oder Excel-Dateien zu PDF konvertieren, oder nur HTML-Seiten?
Gotenberg konvertiert neben HTML-Seiten auch Word-, Excel- und PowerPoint-Dateien zu PDF, weil das volle Docker-Image gotenberg/gotenberg:8 zusätzlich zu Chromium eine LibreOffice-Installation enthält. Der Aufruf läuft über die eigene Route /forms/libreoffice/convert und akzeptiert unter anderem die Formate .docx, .xlsx, .pptx und .odt. Wer ausschließlich HTML zu PDF konvertieren will, kann stattdessen das schlankere Image gotenberg/gotenberg:8-chromium ohne LibreOffice einsetzen und spart damit rund 30 Prozent an Image-Größe.