Convert Markdown to PDF
Converts Markdown content (with MathJaX support) to PDF using Headless Chromium. You provide an index.html template and one or more .md files. Gotenberg converts the Markdown to HTML and injects it into your template.
The index.html file must include the following Go template action where the content should appear:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My PDF</title>
</head>
<body>
{{ toHTML "file.md" }}
</body>
</html>
See the Chromium module configuration for startup and behavior flags.
Basics
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
-o my.pdf
- 200
- 400
- 503
Content-Disposition: attachment; filename={output-filename.pdf}
Content-Type: {content-type}
Content-Length: {content-length}
Gotenberg-Trace: {trace}
Body: {output-file}
Content-Type: text/plain; charset=UTF-8
Gotenberg-Trace: {trace}
Body: {error}
Content-Type: text/plain; charset=UTF-8
Gotenberg-Trace: {trace}
Body: Service Unavailable
Assets
Send images, fonts, and stylesheets alongside your HTML.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form files=@/path/to/img.png \
-o my.pdf
All uploaded files are stored in a single flat directory. Reference assets by filename only: no absolute paths (/img.png) or subdirectories (./assets/img.png).
✅ Correct Reference
Since index.html and logo.png sit side-by-side in the container:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My PDF</title>
</head>
<body>
{{ toHTML "file.md" }}
<img src="logo.png" />
</body>
</html>
❌ Incorrect Reference
Gotenberg does not recreate your local folder structure (e.g., images/) inside the container.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My PDF</title>
</head>
<body>
{{ toHTML "file.md" }}
<img src="/images/logo.png" />
</body>
</html>
For remote URLs (CDNs, Google Fonts), make sure the container can resolve the domain. You cannot use localhost from inside the container. Use host.docker.internal or your host's network IP instead.
For small assets, Base64 data URIs eliminate network dependencies entirely. Avoid the HTML <base> element: it breaks the link between your HTML and uploaded assets.
Rendering Behavior
Page Layout
8.5110.390.390.390.39false1.0falsefalsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form paperWidth=8.27 \
--form paperHeight=11.7 \
--form marginTop=1 \
--form marginBottom=1 \
--form marginLeft=1 \
--form marginRight=1 \
--form landscape=true \
--form scale=2.0 \
-o my.pdf
Background Logic
falsefalsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form printBackground=true \
-o my.pdf
The final background depends on printBackground, omitBackground, and your document's CSS:
| Print Background? | Omit Background? | HTML CSS has BG? | Resulting Output |
|---|---|---|---|
false | (Any) | (Any) | No Background |
true | (Any) | Yes | Uses HTML CSS Background |
true | true | No | Transparent |
true | false | No | White (Default) |
When singlePage is true, it overrides paperHeight and nativePageRanges.
Standard Paper Sizes
Dimensions in inches (width × height):
| Format | Dimensions | Format (US) | Dimensions |
|---|---|---|---|
| A6 | 4.13 x 5.83 | Letter | 8.5 x 11 (Default) |
| A5 | 5.83 x 8.27 | Legal | 8.5 x 14 |
| A4 | 8.27 x 11.7 | Tabloid | 11 x 17 |
| A3 | 11.7 x 16.54 | Ledger | 17 x 11 |
| A2 | 16.54 x 23.4 | ||
| A1 | 23.4 x 33.1 | ||
| A0 | 33.1 x 46.8 |
Print Media
Chromium defaults to print media: backgrounds are removed and layout is optimized for ink. If your PDF looks different from the browser, this is probably why.
@media printin CSS lets you hide elements (nav bars, buttons) from the PDF.- Set
emulatedMediaTypetoscreento match the browser viewport. - Enable
printBackgroundto include background colors and images. - Control paper size and margins via the CSS
@pagerule (requirespreferCssPageSize).
printcurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form emulatedMediaType=screen \
-o my.pdf
Emulated Media Features
You can simulate specific browser conditions by overriding CSS media features. This is particularly useful for forcing "Dark Mode" or testing layouts with reduced motion.
The emulatedMediaFeatures field expects a JSON array of objects, where each object contains a name and a value.
Common Media Features:
| Feature Name | Common Values | Description |
|---|---|---|
prefers-color-scheme | light, dark | Emulates the user's OS color theme preference. |
prefers-reduced-motion | no-preference, reduce | Emulates the setting to minimize non-essential motion. |
color-gamut | srgb, p3, rec2020 | Emulates the approximate range of colors supported by the output device. |
forced-colors | none, active | Emulates "High Contrast" modes where the browser restricts colors. |
Nonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form emulatedMediaFeatures='[{"name": "prefers-color-scheme", "value": "dark"}, {"name": "prefers-reduced-motion", "value": "reduce"}]' \
-o my.pdf
JavaScript & Dynamic Content
Chromium captures what is currently visible. If the page relies on JavaScript to render data, charts, or external content, the conversion might trigger before the rendering is complete, resulting in blank or incomplete sections.
If the content is generated dynamically:
- Use the
waitDelayform field to add a fixed pause before conversion. - For more precision, use the
waitForExpressionform field to trigger the conversion only when a specific JavaScript condition (e.g.,window.status === 'ready') is met.
Wait Delay
Use this as a fallback when you cannot modify the target page's code. It forces Gotenberg to wait for a fixed duration before rendering, giving JavaScript time to finish execution.
This method is "brute force". If the page loads faster, time is wasted. If it loads slower, the PDF will be incomplete. Use explicit waits (Expression or Selector) whenever possible.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form waitDelay=5s \
-o my.pdf
Wait For Expression
This is the most robust method for synchronization. It pauses the conversion process until a specific JavaScript expression evaluates to true within the page context. This ensures the PDF is generated exactly when your data is ready.
Example: Client-side logic
// Inside your HTML page.
window.status = "loading";
fetchData().then(() => {
renderCharts();
// Signal to Gotenberg that the page is ready.
window.status = "ready";
});
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
-- form 'waitForExpression=window.status === '\''ready'\''' \
-o my.pdf
Wait For Selector
Ideally suited for Single Page Applications (SPAs) or frameworks like React/Vue. This method delays the conversion until a specific HTML element - identified by a CSS selector - appears in the DOM.
Example: Dynamic Element Injection
// Inside your HTML page.
await heavyCalculation();
const completionMarker = document.createElement("div");
completionMarker.id = "app-ready"; // The selector we wait for.
document.body.appendChild(completionMarker);
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form 'waitForSelector=#app-ready' \
-o my.pdf
HTTP & Networking
Cookies
A JSON array of cookie objects for authentication or session state.
| Key | Description | Default |
|---|---|---|
name | The name of the cookie. | Required |
value | The value of the cookie. | Required |
domain | The domain the cookie applies to (e.g., example.com). | Required |
path | The URL path the cookie applies to. | None |
secure | If true, the cookie is only sent over HTTPS. | None |
httpOnly | If true, the cookie is inaccessible to JavaScript (document.cookie). | None |
sameSite | Controls cross-site behavior. Values: "Strict", "Lax", "None". | None |
Cookies expire when the request reaches its time limit. For strict isolation between conversions, configure the API to clear the cookie jar after every request. See API Configuration.
Nonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form 'cookies=[{"name":"yummy_cookie","value":"choco","domain":"theyummycookie.com"}]' \
-o my.pdf
HTTP Headers
A JSON object of headers sent with every browser request (including images, stylesheets, scripts).
To restrict a header to specific URLs, append ;scope= with a regex. The scope token is stripped before sending.
Example: "X-Internal-Token": "secret-123;scope=.*\\.internal\\.api"
https://data.internal.api/v1→ header senthttps://google.com/fonts→ header not sent
NoneNonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form 'userAgent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)"' \
--form-string 'extraHttpHeaders={"X-Header":"value","X-Scoped-Header":"value;scope=https?:\/\/([a-zA-Z0-9-]+\.)*domain\.com\/.*"}' \
-o my.pdf
Invalid HTTP Status Codes
Return a 409 Conflict if the main page or its resources return specific HTTP status codes (JSON array of integers).
| Field | Description |
|---|---|
failOnHttpStatusCodes | Fails if the main page URL returns a matching code. |
failOnResourceHttpStatusCodes | Fails if any asset (image, CSS, script) returns a matching code. |
Status Code Ranges
Ranges use the X99 notation:
499matches every code from 400 to 499.599matches every code from 500 to 599.
Domain Exclusions
Exclude third-party assets (analytics, tracking scripts) from status code checks with ignoreResourceHttpStatusDomains. Matches if the hostname equals or is a subdomain of the entry. Values are normalized (trimmed, lowercased, port/scheme stripped):
example.com*.example.comor.example.comexample.com:443(port is ignored)https://example.com/path(scheme/path are ignored)
[499,599]NoneNonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form 'failOnHttpStatusCodes=[499,599]' \
--form 'failOnResourceHttpStatusCodes=[499,599]' \
--form 'ignoreResourceHttpStatusDomains=["sentry-cdn.com","analytics.example.com"]' \
-o my.pdf
- 409
Content-Type: text/plain; charset=UTF-8
Gotenberg-Trace: {trace}
Body: Invalid HTTP status code from the main page: 400: Bad Request
Network Errors
If the browser hits a critical network error on the main page, the API returns 400 Bad Request:
net::ERR_CONNECTION_CLOSEDnet::ERR_CONNECTION_RESETnet::ERR_CONNECTION_REFUSEDnet::ERR_CONNECTION_ABORTEDnet::ERR_CONNECTION_FAILEDnet::ERR_NAME_NOT_RESOLVEDnet::ERR_INTERNET_DISCONNECTEDnet::ERR_ADDRESS_UNREACHABLEnet::ERR_BLOCKED_BY_CLIENTnet::ERR_BLOCKED_BY_RESPONSEnet::ERR_FILE_NOT_FOUNDnet::ERR_HTTP2_PROTOCOL_ERROR
By default, Chromium does not wait for network activity to settle before converting. Two form fields control this behavior:
skipNetworkIdleEventset tofalse: waits until zero open connections persist for 500ms. Strictest option, but pages with long-polling or analytics connections may never reach this state.skipNetworkAlmostIdleEventset tofalse: waits until at most 2 open connections persist for 500ms. A practical middle ground for pages that keep a background connection alive (WebSockets, analytics pings, heartbeat polls).
Both default to true (no wait). If both are set to false, both events must fire before conversion proceeds.
A broken image or stylesheet won't fail the conversion on its own. The PDF is generated with missing assets. Set failOnResourceLoadingFailed to true to fail on any resource network error.
truetruefalsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form skipNetworkIdleEvent=false \
--form skipNetworkAlmostIdleEvent=false \
--form failOnResourceLoadingFailed=true \
-o my.pdf
Console
falsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form failOnConsoleExceptions=true \
-o my.pdf
- 409
Content-Type: text/plain; charset=UTF-8
Gotenberg-Trace: {trace}
Body:
Chromium console exceptions:
exception "Uncaught" (17:10): Error: Exception 1
at file:///tmp/db09d2c8-31e3-4058-9923-c2705350f2b3/index.html:18:11;
exception "Uncaught" (20:10): Error: Exception 2
at file:///tmp/db09d2c8-31e3-4058-9923-c2705350f2b3/index.html:21:11:
Header & Footer
Inject a custom header and footer into every page by uploading header.html and footer.html files.
Headers and footers render in a separate Chromium context: your main page's CSS does not apply, JavaScript won't execute, and external resources (images, fonts, stylesheets) won't load.
NoneNonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form files=@/path/to/header.html \
--form files=@/path/to/footer.html \
-o my.pdf
HTML Structure
Each file must be a full HTML document with its own <html>, <head>, and <body> tags.
<html>
<head>
<style>
html {
/* Recommended: Use a larger font-size than normal */
font-size: 16px;
/* Recommended: Use margins to align with the page edge */
margin: 0 20px;
/* Required for background colors to show */
-webkit-print-color-adjust: exact;
}
</style>
</head>
<body>
<p>
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>
Dynamic Content Injection
Chromium automatically injects values into elements with specific class names:
| Class Name | Injected Value |
|---|---|
date | The formatted print date |
title | The document title. |
url | The document location. |
pageNumber | The current page number. |
totalPages | The total number of pages. |
The environment variable TZ allows you to override the default "UTC" timezone.
Styling & Asset Limitations
| Component | Rule |
|---|---|
| Images | Must be Base64 encoded inline (<img src="data:image/png;base64,...">). External URLs won't load. |
| Fonts | Only fonts installed in the Docker image are available. See Fonts Configuration. |
| Colors | Add -webkit-print-color-adjust: exact; to force background/text colors. |
| Margins | Content taller than marginTop / marginBottom will be clipped. |
| CSS | footer.html styles may override header.html styles. Use unique class names. |
Do not load external assets (CSS, images, scripts) in headers or footers. They will time out or cause the request to fail.
Structure & Metadata
Document Outline (Chromium)
Automatically creates a PDF bookmark pane (sidebar) based on your HTML headings (<h1> to <h6>). Your HTML must use proper heading tags to generate the hierarchy.
falsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form generateDocumentOutline=true \
-o my.pdf
Metadata (PDF Engines)
Inject XMP metadata (Author, Title, Copyright, Keywords, etc.) into the PDF as a JSON object.
Not all tags are writable. Gotenberg uses ExifTool under the hood. See the XMP Tag Name documentation for valid keys. Writing metadata usually breaks PDF/A compliance.
Nonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form 'metadata={"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreationDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"}' \
-o my.pdf
Attachments (PDF Engines)
Attach external files directly inside the PDF container. Commonly used for e-invoicing standards like ZUGFeRD / Factur-X, which require a machine-readable XML invoice as an attachment.
Nonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form embeds=@invoice.xml \
--form embeds=@logo.png \
-o my.pdf
Flatten (PDF Engines)
Merges all interactive form fields (text inputs, checkboxes, etc.) into the page content, making the PDF non-editable.
falsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form flatten=true \
-o my.pdf
Split & Page Ranges
Native Printing (Chromium)
Happens during conversion. Faster, as unused pages are never rendered. Control page breaks with CSS: break-inside: avoid, break-before: always, break-after: always.
Nonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form nativePageRanges=1-5 \
-o my.pdf
Post-Processing (PDF Engines)
Splits pages after the PDF has been generated using a PDF Engine (like pdfcpu). Returns a single PDF or a ZIP archive.
When splitMode is set to pages, Gotenberg does not validate the splitSpan syntax. The value is passed directly to the underlying PDF Engine, and the valid syntax depends on which engine you have configured:
| Engine | Syntax Reference |
|---|---|
| pdfcpu (Default) | See pdfcpu /trim documentation |
| QPDF | See QPDF page-ranges documentation |
| PDFtk | See PDFtk cat operation |
Check the PDF Engines Configuration to see which engine is active.
NoneNonefalsecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form splitMode=intervals \
--form splitSpan=1 \
-o my.zip
Watermark & Stamp
Native Watermark (HTML/CSS)
Add a watermark directly in your HTML with CSS pseudo-elements. Rendered behind the page content during conversion, no post-processing needed.
body {
position: relative;
}
body::before {
content: "CONFIDENTIAL";
position: fixed;
inset: 0;
z-index: -1;
display: grid;
justify-content: center;
align-content: center;
font-size: 160px;
font-weight: bold;
opacity: 0.08;
transform: rotate(-45deg);
pointer-events: none;
}
Native Stamp (HTML/CSS)
A stamp works the same way but is rendered on top of the page content using ::after and a positive z-index.
body::after {
content: "APPROVED";
position: fixed;
top: 40px;
right: 40px;
z-index: 9999;
padding: 10px 28px;
border: 4px solid rgba(0, 128, 0, 0.6);
border-radius: 8px;
font-size: 36px;
font-weight: bold;
color: rgba(0, 128, 0, 0.6);
transform: rotate(-12deg);
pointer-events: none;
}
Watermark (PDF Engines)
Adds a watermark behind the content of each page during post-processing. Sources: text, image, or pdf.
The watermarkOptions form field accepts a JSON object whose keys depend on the configured PDF Engine:
| Engine | Syntax Reference |
|---|---|
| pdfcpu (Default) | See pdfcpu watermark documentation |
| pdftk | See pdftk documentation |
Available keys include font, points (font size), color, rotation, opacity, scale, offset, and more. Example:
{
"font": "Helvetica",
"points": 48,
"color": "#808080",
"rotation": 45,
"opacity": 0.15
}
Check the PDF Engines Configuration to see which engine is active.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form watermarkSource=text \
--form watermarkExpression=CONFIDENTIAL \
--form 'watermarkOptions={"opacity":0.25,"rotation":45}' \
-o my.pdf
Stamp (PDF Engines)
Adds a stamp on top of the content of each page during post-processing. Sources: text, image, or pdf.
The stampOptions form field accepts a JSON object whose keys depend on the configured PDF Engine:
| Engine | Syntax Reference |
|---|---|
| pdfcpu (Default) | See pdfcpu stamp documentation |
| pdftk | See pdftk documentation |
Available keys include font, points (font size), color, rotation, opacity, scale, offset, and more. Example:
{
"font": "Helvetica",
"points": 24,
"color": "#008000",
"rotation": 0,
"opacity": 0.6
}
Check the PDF Engines Configuration to see which engine is active.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form stampSource=text \
--form stampExpression=APPROVED \
--form 'stampOptions={"opacity":0.5,"rotation":0}' \
-o my.pdf
Rotate (PDF Engines)
Rotates pages by 90, 180, or 270 degrees during post-processing.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form rotateAngle=90 \
-o my.pdf
PDF/A & PDF/UA
falseNonefalseNative Accessibility (Chromium)
Embeds a logical structure tree (headings, paragraphs) during conversion. Essential for screen readers.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form generateTaggedPdf=true \
-o my.pdf
Post-Processing (PDF Engines)
Converts to PDF/A (archival) or PDF/UA (accessibility) during post-processing using LibreOffice. Slower than native conversion (requires a second pass).
PDF/A and encryption are mutually exclusive: requesting both returns 400 Bad Request. PDF/A-1b and PDF/A-2b don't support file attachments; use PDF/A-3b if you need both.
When PDF/A runs alongside other post-processing, LibreOffice overwrites CreateDate, ModDate, and Keywords. Other metadata fields are preserved.
curl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form pdfa=PDF/A-1b \
--form pdfua=true \
-o my.pdf
Encryption (PDF Engines)
Set passwords to control PDF access. The user password is required to open the PDF; the owner password controls permissions (printing, copying, editing).
Encryption strength (e.g., AES-256) depends on the active PDF Engine. See PDF Engines Configuration.
NoneNonecurl \
--request POST http://localhost:3000/forms/chromium/convert/markdown \
--form files=@/path/to/index.html \
--form files=@/path/to/file.md \
--form userPassword=my_user_password \
--form ownerPassword=my_owner_password \
-o my.pdf

