ScreenshotNeo

BlogHTML to image & PDF

Convert a webpage to PDF with custom fonts in Docker

Use Puppeteer in Docker to render a webpage as a PDF with its custom fonts loaded, embedded, and verified before capture.

By the ScreenshotNeo team4 October 202611 min read

To convert a webpage to PDF with custom fonts in Docker, run Puppeteer with Chromium, make the font files available either to the page through CSS or to the container’s system font directories, wait for fonts to finish loading, then call page.pdf(). Puppeteer waits for document.fonts.ready by default during PDF generation. For repeatable output, use a pinned Puppeteer image, verify the font family and glyph coverage in the built container, and explicitly configure print styles, paper, and margins.

This guide uses Puppeteer and Chromium. Puppeteer’s official documentation covers PDF generation, Docker setup, and the PDF options.

1. Choose how to provide the font

There are two practical approaches. Installing fonts in the image is useful when many pages need the same locally available faces or rendering must work without reaching a font host. Loading fonts through the page’s CSS keeps the font choice with the page, but depends on the font file being reachable and permitted by its license.

Approach Use when Check
Install font files in the image The page expects a system font, multiple pages share the font, or the renderer needs offline access. The font cache is refreshed, Chromium sees the face, and the CSS family name matches.
Load with @font-face You control the page’s CSS or can inject a stylesheet, and the font resource is reachable. Request succeeds, CSS names the right face and weight, and document.fonts.ready resolves.

Use the actual family name declared inside the font or in its CSS, rather than assuming the filename is the family name. For non-Latin text, ensure the selected files contain the required glyphs; a browser may silently substitute a fallback face for missing characters.

2. Build a Docker image with Puppeteer and local fonts

The following example starts from Puppeteer’s published image, adds a font file named BrandSans-Regular.ttf, refreshes the font cache, and runs a script that writes /work/output.pdf. Font directories and cache commands depend on the base distribution; this example follows a common Linux pattern and should be checked against the image you select.

# Dockerfile
FROM ghcr.io/puppeteer/puppeteer:25.12.0
USER root
RUN apt-get update && apt-get install -y --no-install-recommends fontconfig \
    && rm -rf /var/lib/apt/lists/*
COPY fonts/BrandSans-Regular.ttf /usr/local/share/fonts/BrandSans-Regular.ttf
RUN fc-cache -fv
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
COPY render.mjs ./
USER pptruser
ENTRYPOINT ["node", "render.mjs"]

Create the project files:

{
  "name": "webpage-pdf",
  "private": true,
  "type": "module",
  "dependencies": {
    "puppeteer": "25.12.0"
  }
}

Generate and commit a lockfile for this manifest in your own project before using npm ci. Keep the font files in a fonts/ directory beside the Dockerfile. If the base image uses a different package manager or user, adapt the install and runtime steps to that image. The Puppeteer Docker guide documents its published image, sandbox requirements, and use of an init process.

3. Render the webpage and wait for fonts

This Node.js script accepts a URL and output path, navigates, waits for a meaningful page state, explicitly waits for the font set, then writes a PDF. The default navigation wait is domcontentloaded; select a readiness condition that matches the site. networkidle2 can help with pages that load assets after the initial HTML, but analytics, polling, and long-lived requests can prevent network-idle waits from completing.

// render.mjs
import puppeteer from 'puppeteer';

const url = process.env.PAGE_URL ?? 'https://example.com';
const output = process.env.OUTPUT_PDF ?? '/work/output.pdf';
const browser = await puppeteer.launch({ headless: true });

try {
  const page = await browser.newPage();
  page.setDefaultNavigationTimeout(45_000);
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });

  // Optional: wait for a page-specific signal, such as the rendered article.
  // await page.waitForSelector('main article', { timeout: 15_000 });

  // Page.pdf() waits for document.fonts.ready by default. Make the font
  // readiness explicit here so failures can be logged before PDF creation.
  await page.evaluate(async () => {
    await document.fonts.ready;
  });

  const fontReport = await page.evaluate(() => ({
    status: document.fonts.status,
    brandFontAvailable: document.fonts.check('16px "Brand Sans"'),
  }));
  console.log('Font report:', fontReport);

  await page.pdf({
    path: output,
    format: 'A4',
    printBackground: true,
    preferCSSPageSize: true,
    margin: { top: '18mm', right: '16mm', bottom: '18mm', left: '16mm' },
    waitForFonts: true,
    timeout: 45_000,
  });
  console.log(`Wrote ${output}`);
} finally {
  await browser.close();
}

document.fonts.check() checks whether the browser can use a requested font for text; it does not prove every glyph came from that font. For a visual check, render representative text from every script and weight used by your documents, then inspect the resulting PDF.

Build and run it with:

docker build -t webpage-pdf .
docker run --rm --init \
  --cap-add=SYS_ADMIN \
  -e PAGE_URL=https://example.com \
  -e OUTPUT_PDF=/work/output.pdf \
  -v "$PWD/output:/work/output" \
  webpage-pdf

The mount must match the output path. In this example /work/output.pdf is a file, so a simpler matching invocation is:

docker run --rm --init \
  --cap-add=SYS_ADMIN \
  -e PAGE_URL=https://example.com \
  -e OUTPUT_PDF=/work/output/output.pdf \
  -v "$PWD/output:/work/output" \
  webpage-pdf

Create the host directory first with mkdir -p output. The Puppeteer image is designed to run the browser sandboxed and its guide calls for SYS_ADMIN; follow the security model of your deployment environment. The guide also recommends an init process such as Docker’s --init so browser child processes are managed correctly. If your platform does not allow that capability, consult the current Puppeteer Docker guidance and your platform’s browser sandbox policy rather than copying launch flags blindly.

4. Load the custom face through CSS instead

If you can change the page stylesheet, declare the font with @font-face. The browser must be able to fetch the font URL, and the declared family and weight must match the styles used by the page.

@font-face {
  font-family: "Brand Sans";
  src: url("https://static.example.com/fonts/brand-sans-regular.woff2") format("woff2");
  font-style: normal;
  font-weight: 400;
  font-display: swap;
}

@font-face {
  font-family: "Brand Sans";
  src: url("https://static.example.com/fonts/brand-sans-bold.woff2") format("woff2");
  font-style: normal;
  font-weight: 700;
  font-display: swap;
}

body {
  font-family: "Brand Sans", sans-serif;
}

For an HTML page you control, embed equivalent CSS in the document. For a third-party page where you cannot edit its styles, system installation only helps if its CSS asks for that family; it does not automatically replace a different declared font. You may inject CSS with Puppeteer’s page APIs, but consider that this changes the page being printed and can affect pagination.

When the CSS is remote, check browser request failures and response status for the font file. Cross-origin policy, authentication, an incorrect URL, or an unreachable host can prevent it from loading. Prefer self-hosted, versioned font assets when predictable rendering matters, and confirm you have the right to distribute font files inside your image or PDF.

5. Configure print layout and PDF output

page.pdf() renders using print media by default. Use print CSS for page breaks and paper-specific layout. If you need screen styles, call page.emulateMediaType('screen') before PDF generation; otherwise Chromium’s print styles may hide or rearrange content.

@page {
  size: A4;
  margin: 18mm 16mm;
}

@media print {
  .screen-only { display: none !important; }
  h1, h2, h3 { break-after: avoid; }
  pre, blockquote, figure { break-inside: avoid; }
  a { color: inherit; }
}
Option What it controls Practical note
format Paper preset such as A4 or Letter. Takes priority over width and height.
landscape Orientation. Set when tables or layouts need wider pages.
margin Paper margins. Accepts CSS-like units; coordinate with @page.
preferCSSPageSize Whether CSS @page size takes precedence. Useful when the document controls its own paper size.
printBackground Prints background graphics. Enable if colors or background images carry meaning.
pageRanges Subset such as 1-3, 6. Page numbers apply to the generated print layout.
scale Rendering scale from 0.1 to 2. Use sparingly; scaling can change text size and pagination.
displayHeaderFooter, headerTemplate, footerTemplate Print header and footer templates. Template HTML supports date, title, URL, page number, and total pages classes.
waitForFonts Waits for document.fonts.ready. Defaults to true. Keep it enabled unless you have a specific reason.
timeout PDF generation timeout in milliseconds. Set for expected document size; 0 disables the timeout.

See the full current Puppeteer PDFOptions reference for supported properties and defaults. Avoid treating a successful PDF write as proof of visual correctness: inspect page breaks, backgrounds, headers, glyphs, and embedded links on representative content.

6. cURL, Python, and Node.js alternatives

cURL does not render HTML into a PDF by itself. It can download an already generated PDF from an application endpoint, for example:

curl --fail --show-error --location \
  'https://your-app.example.com/render?url=https%3A%2F%2Fexample.com' \
  --output page.pdf

That endpoint must run a renderer such as the Puppeteer service above. Do not expose an unrestricted URL-to-PDF endpoint publicly: validate destinations and apply network controls to avoid letting callers make the renderer fetch internal services or local resources.

Python can invoke the same Dockerized Node renderer and check for a nonzero exit status. This keeps Chromium and font setup in one image instead of duplicating browser installation in Python:

import os
import subprocess

url = "https://example.com"
output_path = os.path.abspath("output/page.pdf")
os.makedirs(os.path.dirname(output_path), exist_ok=True)

subprocess.run(
    [
        "docker", "run", "--rm", "--init", "--cap-add=SYS_ADMIN",
        "-e", f"PAGE_URL={url}",
        "-e", "OUTPUT_PDF=/work/output/page.pdf",
        "-v", f"{os.path.dirname(output_path)}:/work/output",
        "webpage-pdf",
    ],
    check=True,
)
print(f"Wrote {output_path}")

In production, avoid accepting arbitrary URLs without validation. Restrict schemes to HTTP and HTTPS and block access to loopback, private, link-local, and cloud metadata destinations at the network layer; recheck redirects and DNS resolution. These controls matter because the renderer makes outbound requests on a caller’s behalf.

Or skip the browser setup

If you need a screenshot of a webpage rather than a PDF, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. See the ScreenshotNeo API docs for its options. This does not replace Puppeteer when the PDF must contain selectable page text, custom local fonts, or print-specific pagination.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners are accepted and removed before capture, along with supported newsletter popups and chat widgets.
  • Bot checks, blank pages, failed loads, and cache hits are not billed; response headers report the page verdict and billing status.
  • An MCP server provides screenshot and page information tools for AI agents, including Claude, Cursor, and other MCP clients.
  • The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

Troubleshooting

Symptom Likely cause Fix
PDF uses a fallback font Font file is absent from the image, cache was not refreshed, family name differs, or CSS requested a weight/style not installed. Check the installed files and cache in the running image; compare CSS family, weight, and style to the available face; render a sample string.
Some characters appear as boxes or another style The chosen font lacks glyphs for that script or symbols. Use a font with the needed coverage or define a fallback stack that covers those characters.
Remote font works locally but not in Docker Container DNS/network access, TLS, authentication, CORS policy, or the URL differs in the runtime environment. Inspect failed browser requests and test access from the container; use a permitted local font asset if network access is not dependable.
PDF has the right font only sometimes Capture begins before a page-specific font request or client rendering step completes. Wait for the relevant content selector or app-ready signal, then await document.fonts.ready; leave waitForFonts enabled.
Navigation or PDF generation times out Slow page, indefinite network activity, large document, or a font request that never settles. Use an appropriate navigation condition, a page-specific ready signal, and a suitable timeout. Avoid relying on network idle for pages with persistent requests.
Browser exits with sandbox or permission errors Runtime capability, user, or sandbox configuration does not match the browser image. Follow the selected image’s current Docker guide and your platform’s security policy. Do not disable the sandbox as a quick fix in an exposed service.
Container hangs or leaves child processes No init process is reaping browser subprocesses. Run with Docker --init or an equivalent init entrypoint as described by Puppeteer’s Docker guide.
PDF layout differs from browser view PDF uses print media, print CSS applies, backgrounds are omitted, or paper size and margins differ. Choose print or screen media intentionally; set paper and margins; enable printBackground when required.
PDF file is missing or empty Output path is inside the container but not mounted, parent directory is absent, or the process failed before writing. Mount a host directory at the exact output parent, create it first, and propagate the renderer’s exit status.

Performance, reliability, and cost

  • Pin the browser stack. Pin the Puppeteer image tag and dependency lockfile so deployments do not silently change browser or font behavior. Upgrade deliberately and inspect representative PDFs after upgrades.
  • Keep the image focused. Install only required fonts and dependencies. System fonts are baked into the image, so font changes require a rebuild and rollout.
  • Wait for a real readiness signal. Network idle can be slow or never occur on pages with long-lived requests. Prefer a selector or application signal when available, then wait for fonts before printing.
  • Limit concurrency and document size. Each active browser page consumes memory and CPU. Set job timeouts, bound queued work, and close pages and browsers in cleanup paths. Large full-site print layouts and image-heavy documents need more resources than a short page.
  • Make jobs repeatable. Fix locale, timezone, viewport where relevant, print CSS, input URL, and font asset versions. Remote content can change between runs, so retain source and job metadata when reproducibility matters.
  • Budget infrastructure rather than assuming a fixed conversion price. Self-hosted cost depends on the compute, memory, storage, and operational work you allocate; the cited Puppeteer guidance provides no universal cost or timing benchmark.

FAQ

Does Puppeteer embed the custom font in the PDF?

The browser’s PDF output uses the fonts available to its renderer, but this guide does not promise a particular font-embedding outcome for every font or license. Inspect the PDF with your document tooling and confirm distribution rights.

Can I use a WOFF2 font installed in the operating system?

Font support and discovery can vary with the distribution and browser build. For a system-installed font, verify it is recognized inside the chosen image; for page styling, serve WOFF2 through a valid @font-face rule and confirm the request succeeds.

Why is ScreenshotNeo not a drop-in replacement for this PDF workflow?

ScreenshotNeo is useful for webpage captures and can return PDF output, but Puppeteer is the right fit when the requirement depends on custom fonts installed in your container, precise print CSS, or browser-controlled document pagination.