Get your API key
PDF generation in Docker: Playwright, Chromium, and a lean image (2026)

PDF generation in Docker: Playwright, Chromium, and a lean image (2026)

How to generate PDFs in Docker with Playwright or Puppeteer. Dockerfile, system dependencies, fonts, image size, zombie processes, and when to skip Chromium.

10 min read

Generating PDFs in Docker means running a headless browser inside a container: install Playwright or Puppeteer, add the native libraries Chromium links against, install fonts, and call page.pdf(). The work is not the code, which is a dozen lines. The work is the image: a Playwright container with one browser runs 1.2-1.6 GB, Chromium needs about two dozen system packages a slim base image does not ship, and missing fonts silently turn your text into boxes.

This guide covers the working Dockerfile, how to shrink the image, the font and zombie-process traps that only appear in containers, and the point where shipping Chromium in your image stops being worth it.

Can you generate PDFs inside a Docker container?

Yes, and it is the same code you would run outside Docker. The container only changes three things: you must install Chromium's system dependencies, you must add fonts yourself, and you should run an init process so child browser processes get reaped. Everything else is standard Playwright or Puppeteer.

There are three practical approaches, and the right one depends on how much you care about image size versus control.

ApproachImage sizeBest forTrade-off
Official Playwright image1.2-1.6 GBFastest to workingLarge, includes 3 browsers by default
Hand-built Debian slim450-700 MBProduction single-browserYou maintain the dependency list
No browser (WeasyPrint / API)~300 MB or 0Print layouts, thin imagesLess CSS support, or an external call

The official image is the quickest way to a green build. The hand-built image is what most teams ship once they care about pull time and cold start. The no-browser path removes Chromium from your image entirely, which is covered at the end.

What do you need in a Dockerfile to run Playwright?

You need a base image, Chromium's system libraries, the browser binary, fonts, and an init process. The simplest reliable option is the official Playwright image, which already contains the libraries and browsers, so your Dockerfile is short.

# Ships Chromium + all system deps + fonts, pinned to the Playwright version
FROM mcr.microsoft.com/playwright:v1.58.0-jammy
 
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
 
# --init reaps zombie Chromium child processes
ENTRYPOINT ["node", "server.js"]

The render code is identical in both cases. Launch one browser at startup, reuse it, and only open and close a page per request.

import { chromium } from 'playwright';
 
const browser = await chromium.launch({
  args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
 
export async function renderPdf(html) {
  const page = await browser.newPage();
  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    await page.evaluate(() => document.fonts.ready);
    return await page.pdf({ format: 'A4', printBackground: true });
  } finally {
    await page.close(); // keep the browser warm, close the page
  }
}

Why does Chromium fail to launch in Docker?

Chromium fails to launch in Docker almost always because of a missing shared library, not a code bug. A base image like node:22-slim or python:3.12-slim omits about two dozen packages Chromium links against at runtime, so the browser process exits immediately with an error like error while loading shared libraries: libnss3.so.

The libraries Chromium needs include libnss3 (crypto), libgbm1 (GPU buffer management), libasound2 (audio, required even headless), libatk and libatk-bridge (accessibility), and libpango/libcairo (text layout). The hand-built Dockerfile above installs the full set. If you prefer to let the tooling decide, npx playwright install-deps chromium installs the exact list Playwright expects on Debian and Ubuntu.

The alternative to fighting the dependency list is the official mcr.microsoft.com/playwright image, which is built and tested with every library present. It is larger, but it removes the entire class of "works on my machine, crashes in the container" launch failures.

How do you keep the Docker image small?

Use a Debian slim base, install only Chromium (not all three Playwright browsers), and use a multi-stage build so build tools do not ship in the final layer. The official image is convenient but carries WebKit and Firefox you never use for PDFs.

BaseWhat it includesTypical size
mcr.microsoft.com/playwrightChromium + Firefox + WebKit + deps1.2-1.6 GB
node:22-slim + playwright install chromiumOne browser + hand-picked deps450-700 MB
debian:bookworm-slim + apt chromiumSystem Chromium, no Node browsers400-550 MB
alpine + chromium packagemusl Chromium~400 MB

Two settings cut the most weight. First, install a single browser: npx playwright install chromium pulls roughly 170 MB instead of the ~500 MB for all three engines. Second, pass --omit=dev (or --production) to npm ci so devDependencies stay out of the runtime image. A multi-stage build that compiles in one stage and copies only node_modules and build output into a fresh slim stage removes compilers and caches from the shipped layers.

Alpine reaches about 400 MB but uses musl libc instead of glibc, which the Playwright team does not officially support and which surfaces intermittent font and rendering bugs. For a PDF pipeline where output correctness matters, Debian slim is the safer default.

Why do fonts render as empty boxes in a Docker PDF?

Fonts render as boxes because the container has no font files installed. Chromium does not bundle fonts; it reads them from the operating system. A slim base image ships none, so every glyph falls back to a missing-character box (often called tofu).

Install a font package set that matches your content. fonts-liberation covers Latin text (Arial, Times, Courier metric-compatible). For international text you also need fonts-noto-cjk (Chinese, Japanese, Korean) and fonts-noto-color-emoji for emoji, which otherwise render as monochrome boxes. The hand-built Dockerfile above installs all three.

If your template uses a specific brand font, copy the .ttf or .woff2 files into the image and refresh the font cache:

COPY fonts/ /usr/share/fonts/truetype/brand/
RUN fc-cache -f

Then reference the family by name in your CSS, or load it with @font-face. Because the file lives in the image, rendering does not depend on a network fetch to Google Fonts at request time, which removes a flaky external dependency and a per-render latency spike.

How do you avoid zombie Chromium processes in Docker?

Run an init process. Chromium forks child processes (one per tab, plus GPU and utility processes), and when they exit they become defunct entries that need a real PID 1 to reap them. A Node or Python process running as PID 1 does not reap them, so zombies accumulate until the container hits its PID limit and can no longer fork.

The fix is one flag or one package. Either start the container with docker run --init, which injects Docker's built-in tini, or add dumb-init (or tini) as the image entrypoint so it becomes PID 1 and reaps children.

Two more container-specific settings prevent the most common crashes. Chromium writes shared memory to /dev/shm, which Docker caps at 64 MB by default; a large page overflows it and the tab crashes with Target closed. Raise it with --shm-size=1gb, or pass --disable-dev-shm-usage so Chromium writes to /tmp instead. And budget at least 512 MB of memory per container, 1 GB if you render several documents at once, because Chromium idles around 200-400 MB before it renders anything.

Playwright vs Puppeteer vs WeasyPrint in Docker

All three generate PDFs in a container, but they differ in image size, CSS fidelity, and how much you maintain. Playwright and Puppeteer both drive Chromium, so their output is identical; WeasyPrint is a pure-Python renderer with no browser.

ToolEngineImage sizeCSS fidelityContainer notes
PlaywrightChromium450 MB-1.6 GBFull (modern Chromium)install-deps handles libraries
PuppeteerChromium450 MB-1.3 GBFull (modern Chromium)Set PUPPETEER_SKIP_DOWNLOAD to reuse system Chromium
WeasyPrintPython~300 MBPrint CSS subset, no JSNo browser, no zombies, no sandbox flags

Choose Playwright or Puppeteer when your template relies on JavaScript, flexbox and grid, web fonts, or anything a real browser renders. The Playwright vs Puppeteer comparison covers the API differences in depth. Choose WeasyPrint when your documents are static print layouts (invoices, reports) with no JavaScript, because it drops the entire browser and its container overhead. WeasyPrint does not support JavaScript execution or the newest CSS features, so complex templates can render differently than in a browser.

When should you skip the Chromium container entirely?

Skip the container-managed browser when maintaining Chromium starts costing more than the PDFs are worth. The Dockerfile above works, but a browser in production is an operational surface: a 1 GB image slows every deploy and CI run, each Chromium security patch (there were multiple actively-exploited Chromium zero-days in 2026) forces a rebuild and redeploy, and a traffic spike that opens too many pages exhausts memory and crashes the container mid-render.

At that point the browser does not need to live in your image at all. An HTML-to-PDF API runs the same Chromium engine on infrastructure someone else patches, scales, and keeps warm. Your image goes back to a thin service that makes one HTTP call.

// No Chromium, no fonts, no --shm-size, no image bloat
const res = await fetch('https://pdf4.dev/api/v1/render', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.PDF4_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    template_id: 'invoice',
    data: { number: 'INV-001', total: '$1,500.00' },
  }),
});
const pdf = Buffer.from(await res.arrayBuffer());

The template_id points at a stored template, so you send only the row data per render and your service image stays a few megabytes. PDF4.dev keeps a warm browser pool and renders in under 300ms, which is faster than a cold Chromium launch inside a freshly scheduled container.

Not sure the output quality matches a local Chromium? Test any template in the browser with the free Html To PdfTry it free or Webpage To PdfTry it free tools first, then wire up the API once you are happy with the rendering. No Docker image required.

The decision axis is not "which renders better", because Playwright in Docker and a Chromium-based API use the same engine and produce the same bytes. The axis is "do I want to operate a browser?" If you are shipping to Docker anyway and have on-call coverage, the container works. If the PDF pipeline is one feature among many, moving Chromium out of your image removes a recurring maintenance tax. The same trade-off applies to serverless PDF generation, where Chromium fits even more awkwardly.

Summary

A Docker PDF pipeline is a headless browser plus the container plumbing it needs: system libraries so Chromium launches, fonts so text renders, an init process so children get reaped, and enough memory and /dev/shm so large pages do not crash. Use the official Playwright image to get working fast, then move to a hand-built Debian slim image to cut the size to 450-700 MB. If the browser maintenance outgrows the value, WeasyPrint removes it for print layouts and an HTML-to-PDF API removes it entirely. See the Node.js HTML-to-PDF guide for the full render code that runs in any of these setups.

Free tools mentioned:

Html To PdfTry it freeWebpage To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.