Dev

Generate PDFs from HTML with Puppeteer on Linux

Puppeteer HTML to PDF works beautifully on a developer laptop and then falls over on the first clean Linux server it meets, usually with a missing shared library on Debian and Rocky, and with a Chrome sandbox crash on top of that on Ubuntu. The rendering itself is rarely the hard part; the hard part is everything around page.pdf(): the Node.js version, the libraries Chrome expects, the AppArmor policy, how long to wait for JavaScript, and what a renderer that fetches arbitrary URLs does to your threat model.

Original content from computingforgeeks.com - post 173474

This guide takes Puppeteer from npm install to a small PDF microservice that we load tested, packaged in Docker and ran under a hardened systemd unit, with the exact errors we hit on the way. It sits alongside our benchmark of wkhtmltopdf alternatives, which is where the comparison numbers below come from. We ran everything in October 2026 on Ubuntu 26.04 and 24.04, Debian 13 and Rocky Linux 10 with SELinux enforcing, using Puppeteer 25.12.0 and the Chrome for Testing 154 build it downloads, all on x86_64. Puppeteer ships often (25.13.0 appeared on npm while we were writing), so expect newer version strings than ours in your output; the steps did not change between those releases.

Where Puppeteer fits for HTML to PDF

Puppeteer drives a real Chrome, so the PDF shows what a modern browser shows: flexbox, grid, CSS variables, :has(), web fonts and JavaScript that runs before the page prints. In our engine benchmark it passed 12 of 15 paged-media CSS probes and all 3 JavaScript probes, which matches every other Chromium-based option because they share the same print engine.

The three probes it failed matter before you commit to it, because they decide whether your document type fits at all. Chrome does not implement string-set running headers, target-counter() page references for a printed table of contents, or float: footnote, so book-style documents with a generated TOC and real footnotes are a better fit for WeasyPrint, which passed all 15. If your documents are dashboards, invoices or anything that relies on client-side rendering, Puppeteer is the right tool.

The cost angle is where Puppeteer needs planning, because a cold node render.mjs run took 1.5 to 2.5 seconds and peaked around 200 to 220 MiB in that benchmark, against roughly 1 second and 50 MiB for WeasyPrint. The fix for the latency is architectural rather than a tuning flag, and we cover it in the microservice section, where one long-lived browser brought a render down to about 0.3 seconds. If you would rather call Chrome without writing any Node.js, the Chrome headless print-to-pdf guide covers the CLI route, and Playwright is the closest alternative library.

Install a Node.js release Puppeteer accepts

Current Puppeteer declares a Node.js engine floor, and two of our four test systems ship an older Node.js in their default repositories. Check the requirement on the registry before you pick an install method for your distribution:

npm view puppeteer version engines

The engine field is the number that matters for every install decision that follows:

version = '25.12.0'
engines = { node: '>=22.12.0' }

The default repositories on our four test systems ship different Node.js lines, and only two of them meet that floor without an extra repository:

DistributionNode.js from the default repoMeets the engine floorWhat we used
Ubuntu 26.0422.x lineYesapt install nodejs npm
Ubuntu 24.0418.x lineNoNodeSource LTS
Debian 1320.x lineNo, npm warns EBADENGINENodeSource LTS
Rocky Linux 1022.x line (AppStream)Yesdnf install nodejs npm

On Ubuntu 26.04 the archive package is new enough, so the distribution packages are the simplest route. The poppler-utils package adds pdfinfo, pdftotext and pdffonts, which we use throughout to check the PDFs:

sudo apt update
sudo apt install -y nodejs npm poppler-utils

Rocky Linux 10 is the same story, because the AppStream repository already carries a supported Node.js line:

sudo dnf install -y nodejs npm poppler-utils

On Ubuntu 24.04 and Debian 13, add the NodeSource LTS repository instead. Download the setup script, read it if your change process requires that, then run it and install Node.js:

curl -fsSL https://deb.nodesource.com/setup_lts.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs poppler-utils

Confirm the runtime on any of the four systems before installing anything else, because the npm version also matters below:

node -v
npm -v

Our Debian 13 box reported the NodeSource LTS build and the npm release that ships with it:

v24.21.0
11.19.0

If you skip this step on Debian 13, npm install puppeteer still completes but prints the warning below, and you end up running a release that its maintainers do not test on your runtime. For more detail on version management, see the Node.js 24 on Ubuntu guide or the Rocky Linux Node.js guide.

npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE   package: '[email protected]',
npm WARN EBADENGINE   required: { node: '>=22.12.0' },
npm WARN EBADENGINE   current: { node: 'v20.19.2', npm: '9.2.0' }
npm WARN EBADENGINE }

Install Puppeteer and its bundled Chrome

Create a project directory as the user that will own the service, not as root. The scripts in this guide use the .mjs extension, which Node.js always treats as an ES module with top-level await; setting type=module only matters if you later rename them to .js:

mkdir -p ~/pdf-service && cd ~/pdf-service
npm init -y
npm pkg set type=module
npm install puppeteer

The postinstall step downloads Chrome for Testing into ~/.cache/puppeteer. With the npm 11 that NodeSource ships, the install finishes with an extra warning on Ubuntu 24.04 and Debian 13:

added 27 packages, and audited 28 packages in 36s

9 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts   [email protected] (postinstall: node install.mjs)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

In our runs the script still executed and the browser landed in the cache, but the warning is npm telling you that install scripts are moving to an allow list. Approve Puppeteer explicitly so a future npm default does not silently skip the browser download:

npm install-scripts approve puppeteer

npm confirms the approval and records it under allowScripts in package.json, pinned to the installed version, which you should commit with the project:

Approved puppeteer:
  added [email protected]

That pin means the next Puppeteer upgrade arrives unapproved, and its browser download is skipped until you approve again. Run npm install-scripts approve --no-allow-scripts-pin puppeteer if you prefer a name-only approval that covers future versions. This matters more than it looks: we repeated the install with npm 12, which blocks unapproved install scripts outright, and the project ended up with Puppeteer but no browser in the cache at all:

npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   [email protected] (postinstall: node install.mjs)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

Check what landed in the cache, because current Puppeteer fetches both the full Chrome build and the smaller chrome-headless-shell:

ls ~/.cache/puppeteer

Both browsers are present in the cache, and the full chrome build is the one puppeteer.launch() uses by default:

chrome
chrome-headless-shell

Install the shared libraries Chrome needs

A minimal server image doesn’t carry the GTK, NSS and audio libraries Chrome links against, so the first launch fails before any page loads. This is the exact failure on a fresh Ubuntu 26.04 VM, and the clean containers we tested below were missing even more (25 libraries on debian:13, 21 on rockylinux:10):

Error: Failed to launch the browser process:  Code: 127

stderr:
/home/victor/.cache/puppeteer/chrome/linux-154.0.8037.57/chrome-linux64/chrome: error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directory

The commands in this section use a linux-* glob for the browser directory. After a Puppeteer upgrade the cache can hold more than one build, so list them with npx puppeteer browsers list and delete the old directory before reusing these commands, otherwise the glob matches both. Rather than copying a package list from an old blog post, use the dependency manifests that ship inside the downloaded browser. Every Chrome for Testing build carries a deb.deps and an rpm.deps file next to the binary that list the packages that exact build needs, so the list moves with the browser version instead of going stale.

On Ubuntu and Debian, hand deb.deps to apt-get satisfy, which understands the version constraints and alternatives in that file. This is also what the --install-deps flag in Puppeteer does internally, and running it this way doesn’t require npm to run as root:

sudo apt-get satisfy -y --no-install-recommends "$(paste -sd, ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/deb.deps)"

On Rocky Linux 10, feed rpm.deps to dnf one capability per argument. The rpmlib(...) lines describe RPM features rather than packages, so filter them out, and keep the newline delimiter so the rich dependencies with or survive as single arguments:

grep -v '^rpmlib' ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/rpm.deps | xargs -d '\n' sudo dnf install -y

Verify that nothing is left unresolved by asking the dynamic linker for missing libraries:

ldd ~/.cache/puppeteer/chrome/linux-*/chrome-linux64/chrome | grep -c "not found"

A zero means every library the binary links against now resolves on this host:

0

We confirmed both commands from a clean slate as well, mounting the downloaded browser into pristine debian:13 and rockylinux/rockylinux:10 containers: Debian went from 25 missing libraries to 0 after apt-get satisfy, and Rocky went from 21 to 0 after the dnf command.

Fix “No usable sandbox!” on Ubuntu without –no-sandbox

With the libraries in place, Debian 13 and Rocky Linux 10 launch Chrome straight away. Ubuntu 26.04 and 24.04 do not, and the error is the one that sends most people to --no-sandbox. This is the full message from our 26.04 VM, and 24.04 printed the same FATAL ... No usable sandbox! line:

Error: Failed to launch the browser process:  Code: null

stderr:
[8386:8386:1008/103000.264184:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md. Otherwise see https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md for more information on developing with the (older) SUID sandbox. If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox.

The Chrome sandbox uses unprivileged user namespaces, and since 23.10 Ubuntu only allows them for binaries that an AppArmor profile explicitly permits. The stock profile in /etc/apparmor.d/chrome covers Google Chrome at /opt/google/chrome/chrome, which is why a browser from our Google Chrome on Ubuntu guide launches fine, but it does not cover the Chrome for Testing binary Puppeteer keeps in your home directory. The kernel switch confirms the restriction is active on a default Ubuntu 26.04 install:

sysctl kernel.apparmor_restrict_unprivileged_userns

A value of 1 means the restriction is active and applies to every binary that lacks a profile:

kernel.apparmor_restrict_unprivileged_userns = 1

Turning that switch off fixes Puppeteer and also removes the protection for every other program on the host, and --no-sandbox removes the isolation Chrome itself provides from a process whose whole job is parsing HTML you may not control. The approach the Chromium documentation calls safer is a narrow profile that grants user namespaces to exactly the Puppeteer browser path. Create the profile file:

sudo vim /etc/apparmor.d/chrome-puppeteer

Add this profile. @{HOME} is the AppArmor tunable for every user’s home directory, so it covers whichever account runs the service:

abi <abi/4.0>,
include <tunables/global>

profile chrome-puppeteer @{HOME}/.cache/puppeteer/chrome/*/chrome-linux64/chrome flags=(unconfined) {
  userns,

  include if exists <local/chrome-puppeteer>
}

Load the profile without rebooting and confirm that AppArmor now lists it alongside the existing ones:

sudo apparmor_parser -r /etc/apparmor.d/chrome-puppeteer
sudo aa-status | grep chrome

The new profile is loaded and sits next to the stock Chrome profile that Ubuntu ships for Google Chrome:

   chrome
   chrome-puppeteer

The profile is unconfined apart from the user namespace grant, which is the same shape Ubuntu uses for Google Chrome, and it means Chrome keeps its sandbox. The catch, which the Chromium documentation spells out, is that the path sits in a directory the user can write to, so anyone able to drop an executable named chrome under a matching home path gets user namespaces too. On a single-purpose PDF host that is an acceptable trade. On a shared host, keep the browser in a root-owned cache instead, and scope the profile to that path.

Download the browser into /opt/puppeteer as root, using the install script that ships with the project’s Puppeteer package:

cd ~/pdf-service
sudo mkdir -p /opt/puppeteer
sudo env PUPPETEER_CACHE_DIR=/opt/puppeteer node node_modules/puppeteer/install.mjs

The install script reports both downloads:

**INFO** Skipping Firefox download as instructed.
chrome (154.0.8037.57) downloaded to /opt/puppeteer/chrome/linux-154.0.8037.57
chrome-headless-shell (154.0.8037.57) downloaded to /opt/puppeteer/chrome-headless-shell/linux-154.0.8037.57

Create a profile scoped to the root-owned path:

sudo vim /etc/apparmor.d/chrome-puppeteer-opt

Add this profile:

abi <abi/4.0>,
include <tunables/global>

profile chrome-puppeteer-opt /opt/puppeteer/chrome/*/chrome-linux64/chrome flags=(unconfined) {
  userns,

  include if exists <local/chrome-puppeteer-opt>
}

Load it, then render with PUPPETEER_CACHE_DIR pointing at the shared cache. The systemd unit later needs the same variable, which we come back to below:

sudo apparmor_parser -r /etc/apparmor.d/chrome-puppeteer-opt
PUPPETEER_CACHE_DIR=/opt/puppeteer node render.mjs brand.html brand-opt.pdf

We tested this with the home-directory profile unloaded. The /opt browser rendered, a normal user got Permission denied when trying to write into /opt/puppeteer/chrome, and the copy in the home directory still failed with No usable sandbox, which is exactly the scoping we want. Once the shared cache works, retire the home-directory profile so it no longer grants user namespaces to a user-writable path:

sudo apparmor_parser -R /etc/apparmor.d/chrome-puppeteer && sudo rm /etc/apparmor.d/chrome-puppeteer

When you run the systemd unit from the next sections on such a host, add Environment=PUPPETEER_CACHE_DIR=/opt/puppeteer to its [Service] block so the service uses the shared browser. The brand.html test page is created in the setContent() section below. Debian 13 needs none of this because it does not ship the restriction at all, as the sysctl on our Debian box showed:

sysctl: cannot stat /proc/sys/kernel/apparmor_restrict_unprivileged_userns: No such file or directory
kernel.unprivileged_userns_clone = 1

Render your first PDF with page.pdf()

The first script loads a local HTML file through a file:// URL, waits for the network to go quiet, and prints it on A4. Create the script in the project directory so it can import the local Puppeteer install:

vim ~/pdf-service/render.mjs

Paste the script, which is deliberately small so it can serve as the base for the later examples:

import puppeteer from 'puppeteer';
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const [input, output = 'out.pdf'] = process.argv.slice(2);
if (!input) {
  console.error('usage: node render.mjs <input.html> [output.pdf]');
  process.exit(1);
}

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto(pathToFileURL(path.resolve(input)).href, { waitUntil: 'networkidle0' });
  await page.pdf({
    path: output,
    format: 'A4',
    printBackground: true,
    preferCSSPageSize: true,
  });
  console.log(`wrote ${output}`);
} finally {
  await browser.close();
}

Point the script at any HTML document of yours, saved as invoice.html in the project directory. Ours was the 140-line invoice from the benchmark, which carries its own @page rules; check the result with pdfinfo:

cd ~/pdf-service
node render.mjs invoice.html invoice.pdf
pdfinfo invoice.pdf | grep -E "Pages|Page size|Producer|Tagged"

The PDF comes out as a four-page, tagged A4 document produced by the Skia backend inside Chrome, which is what a successful first render looks like:

wrote invoice.pdf
Producer:        Skia/PDF m154
Tagged:          yes
Pages:           4
Page size:       594.96 x 841.92 pts (A4)

Our invoice puts its own page size and a “Page X of Y” counter in CSS @page rules, which is why preferCSSPageSize is on: without it Puppeteer’s format wins and CSS page sizes, including named landscape pages, are ignored.

page.pdf() options that matter in production

The options below are the ones we set or deliberately left alone, with defaults taken from the current Puppeteer PDFOptions reference:

OptionDefaultWhy you would change it
formatletterMost of the world wants A4; US Letter is the silent default
printBackgroundfalseTable header colours, card backgrounds and brand bars vanish without it
preferCSSPageSizefalseLets @page { size } and named pages win over format
marginnoneRequired room for headerTemplate and footerTemplate
displayHeaderFooterfalseTurns on the header and footer templates
landscapefalseWhole-document landscape; use CSS named pages for mixed orientation
scale10.1 to 2; shrink wide tables instead of fighting CSS
outlinefalseBuilds PDF bookmarks from headings (marked experimental)
taggedtrueAccessible structure tree; roughly quadruples file size on table-heavy pages
timeout30000 msFail fast inside a service instead of holding a worker for 30 seconds
waitForFontstrueWaits for document.fonts.ready before printing

The tagged default surprised us. Printing the same invoice twice from one page, once with the default and once with tagged: false, gave very different sizes:

tagged=true 298 KB
tagged=false 69 KB

Tagged PDFs carry a structure tree that screen readers and PDF/UA validators use, so keep the default for anything a customer reads, and turn it off for internal artefacts where storage or email attachment limits matter more. The 69 KB figure also explains why the PDFs from Playwright looked four times smaller in our benchmark: it’s the same engine with a different default.

Add headers, footers and page numbers

The header and footer templates in Puppeteer are tiny HTML documents that Chrome renders into the page margins, with special classes filled in at print time: pageNumber, totalPages, title, url and date. Create the script next to the first one in the project directory:

vim ~/pdf-service/header-footer.mjs

Add the templates and the matching margins, since a template only renders inside the space the margin leaves for it:

import puppeteer from 'puppeteer';
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const [input, output = 'header-footer.pdf'] = process.argv.slice(2);

const headerTemplate = `
  <div style="font-size:8px; width:100%; padding:0 15mm; color:#555; display:flex; justify-content:space-between;">
    <span>ACME Hosting Ltd</span><span class="title"></span>
  </div>`;
const footerTemplate = `
  <div style="font-size:8px; width:100%; padding:0 15mm; color:#555; text-align:right;">
    Page <span class="pageNumber"></span> of <span class="totalPages"></span>
  </div>`;

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto(pathToFileURL(path.resolve(input)).href, { waitUntil: 'networkidle0' });
  await page.pdf({
    path: output,
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
    margin: { top: '22mm', bottom: '20mm', left: '15mm', right: '15mm' },
    outline: true,
  });
  console.log(`wrote ${output}`);
} finally {
  await browser.close();
}

Render a multi-page document (we used the five-page report from the benchmark, saved as report.html) and pull the header and footer text out of page 2:

node header-footer.mjs report.html report-hf.pdf
pdftotext -layout -f 2 -l 2 report-hf.pdf - | grep -E "ACME|Page [0-9]+ of"

The template header and the computed page count both appear:

ACME Hosting Ltd                                                                        Quarterly Infrastructure Report
                                                                                                           Page 2 of 5

Three details catch people out when they move from the CSS approach to the templates. The templates don’t inherit the stylesheet of the page, so set a font size inline or the text renders at a size that looks like an empty margin. The margins must be large enough to hold the templates, because the template is clipped to the margin box. And current Chrome also supports CSS @page margin boxes, so the centred page number from the report stylesheet prints underneath the template footer, which you can see on the rendered page below. Pick one mechanism per document, or you end up with two page numbers on every page as we did here.

Puppeteer PDF page with header template, Page 2 of 5 footer and CSS page counter

That page also shows the CSS gap mentioned earlier in this guide: the float: footnote text in the report stays inline in the paragraph instead of moving to the bottom of the page.

Wait for JavaScript the right way

Pages that build content in JavaScript are the main reason to choose Puppeteer over a pure CSS engine, and the wait strategy decides whether that content makes it into the PDF. This test page draws an SVG chart on load and adds a status line 500 ms later through setTimeout, the same pattern a chart library or an API call produces. Create it:

vim ~/pdf-service/dashboard.html

Paste the markup, which keeps only the two moving parts that matter for the test:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Dashboard</title></head>
<body>
<h1>Service dashboard</h1>
<div id="chart"></div>
<div id="status"></div>
<script>
  document.getElementById('chart').innerHTML =
    '<svg width="300" height="80"><rect width="60" height="80" fill="#0f766e"/><rect x="80" y="30" width="60" height="50" fill="#0f766e"/></svg>';
  document.getElementById('status').insertAdjacentHTML('beforeend', '<p>JS_RENDERED_OK</p>');
  setTimeout(() => {
    document.getElementById('status').insertAdjacentHTML('beforeend', '<p>JS_DELAYED_OK</p>');
  }, 500);
</script>
</body>
</html>

This script compares the three strategies that people usually reach for:

vim ~/pdf-service/wait-for-js.mjs

Add the comparison script, which takes the wait mode as its third argument and reports how long the page took to become ready:

import puppeteer from 'puppeteer';
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const [input, output = 'dashboard.pdf', mode = 'ready'] = process.argv.slice(2);
const url = pathToFileURL(path.resolve(input)).href;

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  const t0 = performance.now();
  if (mode === 'idle') {
    await page.goto(url, { waitUntil: 'networkidle0' });
  } else {
    await page.goto(url, { waitUntil: 'load' });
  }
  if (mode === 'ready') {
    await page.waitForFunction(
      () => document.querySelector('#status')?.textContent.includes('JS_DELAYED_OK'),
      { timeout: 10000 },
    );
  }
  const waited = Math.round(performance.now() - t0);
  await page.pdf({ path: output, format: 'A4', printBackground: true });
  console.log(`wrote ${output} (mode=${mode}, page ready after ${waited} ms)`);
} finally {
  await browser.close();
}

Run all three modes and list which JavaScript markers reached the PDF:

for m in load idle ready; do node wait-for-js.mjs dashboard.html dash-$m.pdf $m; echo "  markers: $(pdftotext dash-$m.pdf - | grep -oE "JS_[A-Z_]+OK" | tr "\n" " ")"; done

The output shows how much the choice of wait condition changes both correctness and speed:

wrote dash-load.pdf (mode=load, page ready after 40 ms)
  markers: JS_RENDERED_OK 
wrote dash-idle.pdf (mode=idle, page ready after 554 ms)
  markers: JS_RENDERED_OK JS_DELAYED_OK 
wrote dash-ready.pdf (mode=ready, page ready after 545 ms)
  markers: JS_RENDERED_OK JS_DELAYED_OK 

The load event fires before the delayed content exists, so that PDF silently misses data. networkidle0 looks fine here, but only because it waits for 500 ms of network silence, which happens to match a 500 ms timer. Raising the timer to 1.5 seconds with sed exposes the problem:

sed "s/}, 500);/}, 1500);/" dashboard.html > dashboard-slow.html
for m in idle ready; do node wait-for-js.mjs dashboard-slow.html slow-$m.pdf $m; echo "  markers: $(pdftotext slow-$m.pdf - | grep -oE "JS_[A-Z_]+OK" | tr "\n" " ")"; done

networkidle0 now prints the page a second in and drops the delayed content, while the readiness check waits for it:

wrote slow-idle.pdf (mode=idle, page ready after 1019 ms)
  markers: JS_RENDERED_OK 
wrote slow-ready.pdf (mode=ready, page ready after 1556 ms)
  markers: JS_RENDERED_OK JS_DELAYED_OK 

Waiting for an explicit readiness condition with waitForFunction() is the only one of the three that stays correct when the page gets slower. In practice this means your templates should set a flag or a DOM marker when they finish rendering, and the PDF code should wait for that, never for a fixed delay.

setContent() versus goto() for generated HTML

Services usually build HTML in memory and pass it with page.setContent() instead of writing a file and calling goto(). Two behaviours change when you do that, and we measured both.

First, the page has no base URL, so relative asset paths such as url("fonts/Lobster-Regular.ttf") resolve to nothing. A small brand page with a web font shows the effect. Create it:

vim ~/pdf-service/brand.html

The page loads a web font through a relative path, which is exactly what breaks later:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Brand font test</title>
<style>
@font-face { font-family: "Lobster"; src: url("fonts/Lobster-Regular.ttf") format("truetype"); }
h1 { font-family: "Lobster", cursive; font-size: 40pt; }
</style>
</head>
<body>
<h1>Invoice</h1>
<p>Body text in the default font.</p>
</body>
</html>

Download the font it references from the Google Fonts repository (use your own font file in practice):

mkdir -p ~/pdf-service/fonts
curl -fsSLo ~/pdf-service/fonts/Lobster-Regular.ttf https://github.com/google/fonts/raw/main/ofl/lobster/Lobster-Regular.ttf

Then create a script that renders through setContent() instead of a URL:

vim ~/pdf-service/set-content.mjs

Add the script:

import fs from 'node:fs/promises';
import puppeteer from 'puppeteer';

const [input, output = 'set-content.pdf'] = process.argv.slice(2);
const html = await fs.readFile(input, 'utf8');

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'load' });
  await page.pdf({ path: output, format: 'A4', printBackground: true });
  console.log(`wrote ${output}`);
} finally {
  await browser.close();
}

Render the page both ways and list the embedded fonts:

cd ~/pdf-service
node render.mjs brand.html brand-goto.pdf && pdffonts brand-goto.pdf
node set-content.mjs brand.html brand-setcontent.pdf && pdffonts brand-setcontent.pdf

Through goto() the font loads; through setContent() Chrome silently falls back to Liberation Serif:

wrote brand-goto.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+Lobster-Regular               Type 3            Custom           yes yes yes      4  0
AAAAAA+Lobster-Regular               Type 3            Custom           yes yes yes      5  0
BAAAAA+LiberationSerif               CID TrueType      Identity-H       yes yes yes      6  0
wrote brand-setcontent.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+LiberationSerif-Bold          CID TrueType      Identity-H       yes yes yes      4  0
BAAAAA+LiberationSerif               CID TrueType      Identity-H       yes yes yes      5  0

Installing the font system-wide fixes it, because Chrome resolves the family name through fontconfig:

sudo mkdir -p /usr/local/share/fonts/lobster
sudo cp ~/pdf-service/fonts/Lobster-Regular.ttf /usr/local/share/fonts/lobster/
sudo fc-cache -f
node set-content.mjs brand.html brand-setcontent.pdf && pdffonts brand-setcontent.pdf

The same setContent() render now embeds Lobster:

wrote brand-setcontent.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+Lobster-Regular               Type 3            Custom           yes yes yes      4  0
AAAAAA+Lobster-Regular               Type 3            Custom           yes yes yes      5  0
BAAAAA+LiberationSerif               CID TrueType      Identity-H       yes yes yes      6  0

The alternatives are inlining fonts and images as data: URIs or serving assets from an internal URL you explicitly allow. Second, the wait condition costs far more with setContent() than we expected. We timed eight renders of the invoice for each combination, with and without the request guard from the next section:

waitUntil=load guard=false setContent_ms=11 pdf_ms=205
waitUntil=load guard=true setContent_ms=9 pdf_ms=190
waitUntil=networkidle0 guard=false setContent_ms=1921 pdf_ms=140
waitUntil=networkidle0 guard=true setContent_ms=1914 pdf_ms=143

With inline HTML there’s no network activity to wait for, yet networkidle0 still spends almost two seconds per render. Use waitUntil: 'load' plus a waitForFunction() readiness check for any JavaScript-built content.

Block file:// and private network requests

A headless browser that renders user-supplied HTML will fetch whatever that HTML points at. In the security probe from our benchmark, Puppeteer loading a document through a file:// URL printed the contents of /etc/passwd from an <iframe> straight into the PDF. Switching to setContent() stopped the file read but not the network fetches, and a stylesheet and an image pointed at a listener on 127.0.0.1 were both requested. That is a server-side request forgery primitive against your own internal network and cloud metadata endpoints.

Request interception lets the page load only what you allow. This module blocks every scheme except http, https, data and about, and rejects loopback, RFC 1918, link-local, IPv6 private addresses and IPv4-mapped IPv6 addresses such as [::ffff:127.0.0.1]. The service in the next section imports it, so write it once as a module:

vim ~/pdf-service/safe-render.mjs

Add the guard module, which the service imports later so every request passes through the same rules:

import fs from 'node:fs/promises';
import net from 'node:net';
import puppeteer from 'puppeteer';

const PRIVATE = [/^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /^0\./];

export function blocked(rawUrl) {
  const u = new URL(rawUrl);
  if (u.protocol === 'data:' || u.protocol === 'about:') return false;
  if (u.protocol !== 'https:' && u.protocol !== 'http:') return true;
  const host = u.hostname.replace(/^\[|\]$/g, '');
  if (host === 'localhost' || host.endsWith('.local') || host === '::1' || host === '::') return true;
  if (net.isIP(host) === 4 && PRIVATE.some((re) => re.test(host))) return true;
  if (net.isIP(host) === 6 && /^(fc|fd|fe80|::ffff:)/i.test(host)) return true;
  return false;
}

export async function guard(page) {
  await page.setRequestInterception(true);
  page.on('request', (req) => {
    if (blocked(req.url())) {
      console.error(`blocked ${req.url()}`);
      req.abort('accessdenied');
    } else {
      req.continue();
    }
  });
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const [input, output = 'safe.pdf'] = process.argv.slice(2);
  const html = await fs.readFile(input, 'utf8');
  const browser = await puppeteer.launch();
  try {
    const page = await browser.newPage();
    await guard(page);
    await page.setContent(html, { waitUntil: 'load' });
    await page.pdf({ path: output, format: 'A4', printBackground: true });
    console.log(`wrote ${output}`);
  } finally {
    await browser.close();
  }
}

Build a hostile test page that reads a local file and points a stylesheet and two images at a listener on 127.0.0.1, one of them through an IPv4-mapped IPv6 address:

vim ~/pdf-service/security.html

Paste this deliberately hostile markup; nothing in it is fetched from outside the host:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Security probe</title>
<link rel="stylesheet" href="http://127.0.0.1:8099/ssrf-css.css">
</head>
<body>
<p>SEC_PROBE_START</p>
<iframe src="file:///etc/passwd" width="600" height="200"></iframe>
<img src="http://127.0.0.1:8099/ssrf-img.png" alt="">
<img src="http://[::ffff:127.0.0.1]:8099/ssrf-mapped.png" alt="">
<p>MARKER_END</p>
</body>
</html>

Test the guard with a throwaway HTTP listener on 127.0.0.1 that logs every request it receives:

mkdir -p /tmp/ssrf && (python3 -m http.server 8099 --bind 127.0.0.1 --directory /tmp/ssrf > /tmp/ssrf/access.log 2>&1 & echo $! > /tmp/ssrf.pid)
sleep 1
node safe-render.mjs security.html guarded.pdf
grep -c "GET /ssrf" /tmp/ssrf/access.log; kill $(cat /tmp/ssrf.pid)

A bare setContent() render of the same file produced 3 hits on this listener in our run, including the IPv4-mapped address, which Chrome rewrites to [::ffff:7f00:1] before the request goes out. With the guard, all three requests are aborted before they leave Chrome and the listener sees nothing:

blocked http://127.0.0.1:8099/ssrf-css.css
blocked http://127.0.0.1:8099/ssrf-img.png
blocked http://[::ffff:7f00:1]:8099/ssrf-mapped.png
wrote guarded.pdf
0

The guard checks hostnames and literal IP addresses, not where a public hostname resolves at request time, so a DNS name pointing at 10.0.1.50 would still pass. Interception also does not see WebSocket traffic, so a page script can still open a socket to an internal host; for HTML you do not trust, call page.setJavaScriptEnabled(false) before loading it, which also removes most of the ways a page can build URLs at runtime. Treat interception as the first layer and pair it with network policy that denies the host or container running the renderer any egress to internal ranges and to 169.254.169.254, which is where we’d reach for a dedicated subnet or a Kubernetes NetworkPolicy rather than more JavaScript.

Run Puppeteer as a PDF microservice

Launching a browser per request is what makes the cold numbers slow. The service below launches Chrome once, exits if Chrome dies so systemd can restart it cleanly, gives every request its own incognito browser context so cookies and storage never leak between customers, limits concurrent pages with a small semaphore, rejects bodies over 5 MB, applies the request guard, and binds to 127.0.0.1 so only a local reverse proxy can reach it. It uses the plain node:http module so there is nothing else to install:

vim ~/pdf-service/server.mjs

Add the service, which is the file that ties the browser, the guard and the HTTP listener together:

import http from 'node:http';
import puppeteer from 'puppeteer';
import { guard } from './safe-render.mjs';

const HOST = process.env.HOST ?? '127.0.0.1';
const PORT = Number(process.env.PORT ?? 8002);
const MAX_PAGES = Number(process.env.MAX_PAGES ?? 4);
const MAX_BODY = 5 * 1024 * 1024;

const browser = await puppeteer.launch();
browser.on('disconnected', () => {
  console.error('browser disconnected, exiting so systemd restarts the service');
  process.exit(1);
});
let active = 0;
const queue = [];
const acquire = () => (active < MAX_PAGES ? (active++, Promise.resolve()) : new Promise((r) => queue.push(r)));
const release = () => (queue.length ? queue.shift()() : active--);

async function render(html) {
  await acquire();
  let context;
  try {
    context = await browser.createBrowserContext();
    const page = await context.newPage();
    await guard(page);
    await page.setContent(html, { waitUntil: 'load', timeout: 20000 });
    return await page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true, timeout: 20000 });
  } finally {
    await context?.close();
    release();
  }
}

const server = http.createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200).end('ok\n');
    return;
  }
  if (req.method !== 'POST' || req.url !== '/pdf') {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY) {
      res.writeHead(413).end('payload too large\n');
      return;
    }
    chunks.push(chunk);
  }
  try {
    const pdf = await render(Buffer.concat(chunks).toString('utf8'));
    res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': pdf.length });
    res.end(pdf);
  } catch (err) {
    console.error(err.message);
    res.writeHead(500).end(`render failed: ${err.message}\n`);
  }
});

server.listen(PORT, HOST, () => console.log(`pdf service on ${HOST}:${PORT}, ${MAX_PAGES} pages max`));

for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, async () => {
    server.close();
    browser.removeAllListeners('disconnected');
    await browser.close();
    process.exit(0);
  });
}

Run it under systemd so it restarts on failure and gets resource limits. A template unit keeps the user name out of the file: pdf-service@victor runs as the user victor from /home/victor/pdf-service, so replace victor in the commands below with the account that owns your project directory:

sudo vim /etc/systemd/system/[email protected]

Add the unit, which sets the restart policy, a memory ceiling and a few systemd hardening options:

[Unit]
Description=Puppeteer HTML to PDF service (%i)
After=network-online.target
Wants=network-online.target

[Service]
User=%i
WorkingDirectory=/home/%i/pdf-service
Environment=PORT=8002
Environment=MAX_PAGES=4
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=2
MemoryMax=2G
TasksMax=512
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full

[Install]
WantedBy=multi-user.target

NoNewPrivileges does not interfere with the AppArmor profile or the user namespace sandbox; we confirmed the service starts and renders with it set on Ubuntu 26.04. Enable and start the instance for the account that owns the project directory:

sudo systemctl daemon-reload
sudo systemctl enable --now pdf-service@victor
systemctl status pdf-service@victor --no-pager | head -8

The service shows one Node.js process and Chrome under the same cgroup, inside the 2 GB memory cap:

● [email protected] - Puppeteer HTML to PDF service (victor)
     Loaded: loaded (/etc/systemd/system/[email protected]; enabled; preset: enabled)
     Active: active (running) since Thu 2026-10-08 13:44:31 UTC; 3s ago
 Invocation: a7c91426544346eca190ede1dbf8b882
   Main PID: 42332 (node)
      Tasks: 112 (limit: 512)
     Memory: 167.1M (max: 2G, available: 1.8G, peak: 167.2M)
        CPU: 1.135s

Post an HTML document to the endpoint and check the status code, the size and the time curl reports:

curl -s -o invoice.pdf -w "%{http_code} %{size_download} bytes %{time_total}s\n" --data-binary @invoice.html http://127.0.0.1:8002/pdf

A warm render of the 140-line invoice came back in about 0.3 seconds:

200 305224 bytes 0.298953s

Oversized bodies are rejected before they reach Chrome, which matters when the endpoint sits behind a public form. Generate a 6 MB file and post it:

head -c 6000000 /dev/zero | tr "\0" a > big.html
curl -s -w "%{http_code}\n" --data-binary @big.html http://127.0.0.1:8002/pdf

The service answers with a 413 instead of rendering:

payload too large
413

To prove the restart path, we killed the Chrome process under the running service. The disconnected handler exited Node.js, and systemd brought the service back within its two-second restart delay, after which the next request returned 200:

browser disconnected, exiting so systemd restarts the service
[email protected]: Main process exited, code=exited, status=1/FAILURE
[email protected]: Scheduled restart job, restart counter is at 1.

On Rocky Linux 10 the same unit ran with SELinux enforcing. Node.js and Chrome ran as unconfined_service_t, the request returned 200 in 0.6 seconds, and ausearch -m avc -ts recent reported no denials, so no extra SELinux policy was needed for a service running from a home directory.

How far one browser goes

We load tested the service on the 2 vCPU, 4 GB Ubuntu 26.04 VM with MAX_PAGES=4, sending 40 invoice renders at three concurrency levels after one warm-up request:

seq 40 | xargs -P 4 -I{} curl -s -o /dev/null -w "%{http_code} %{time_total}\n" --data-binary @invoice.html http://127.0.0.1:8002/pdf

The results, with the systemd memory peak for each run:

ConcurrencySuccessfulThroughputp50 latencyp95 latencyMemory peak
140/403.6 renders/s0.26 s0.27 s255 MiB
440/404.6 renders/s0.85 s1.11 s423 MiB
840/404.6 renders/s1.70 s1.89 s416 MiB

Throughput flattens at under five renders per second because two vCPUs are saturated, so extra concurrency only adds queueing latency, and memory stops growing once the page cap is reached. Our first version of the service used waitUntil: 'networkidle0', which put the single-request p50 at 2.21 seconds and produced one 20-second navigation timeout out of 20 requests at concurrency 8. Switching to load was the largest performance change we made.

For sizing, the relationship is direct, because CPU sets throughput and the page cap sets memory. A reasonable starting point is MAX_PAGES at one to two per vCPU with 100 MiB of headroom per page, then add instances behind a load balancer when p95 latency matters more than cost.

Run the service in Docker

The Puppeteer project publishes an image on the GitHub Container Registry with Chrome for Testing, its libraries and a matching Puppeteer preinstalled, tagged by Puppeteer version. Build the service on top of it so the image tag and the library version always match:

vim ~/pdf-service/Dockerfile

Add the Dockerfile. The HOST variable makes the server listen on all interfaces inside the container:

ARG PPTR_VERSION=latest
FROM ghcr.io/puppeteer/puppeteer:${PPTR_VERSION}

WORKDIR /home/pptruser
COPY --chown=pptruser:pptruser server.mjs safe-render.mjs ./
ENV HOST=0.0.0.0 PORT=8002 MAX_PAGES=4
EXPOSE 8002
CMD ["node", "server.mjs"]

Look up the current Puppeteer release and build an image pinned to that tag, so a rebuild next month is a deliberate upgrade rather than a surprise. Docker itself is covered in our Docker CE on Ubuntu 26.04 guide:

cd ~/pdf-service
export PPTR_VERSION=$(npm view puppeteer version)
docker build --build-arg PPTR_VERSION="${PPTR_VERSION}" -t pdf-service:"${PPTR_VERSION}" .

Stop the systemd instance if it’s running on the same port, then start the container. The image runs Chrome with its sandbox, which needs the SYS_ADMIN capability under the default Docker seccomp profile, and --init reaps the Chrome child processes. SYS_ADMIN is a broad capability; the tighter option is a custom seccomp profile that only adds the namespace system calls Chrome needs, passed with --security-opt seccomp=, which is worth the effort when the container renders untrusted HTML:

sudo systemctl disable --now pdf-service@victor
docker run -d --name pdf-service --restart unless-stopped --init --cap-add=SYS_ADMIN -p 127.0.0.1:8002:8002 pdf-service:"${PPTR_VERSION}"

Give Chrome a few seconds to start, then read the container log:

docker logs pdf-service

It prints the same startup line as the systemd service:

pdf service on 0.0.0.0:8002, 4 pages max

The same curl request returned a PDF in 0.37 seconds. By the time we rebuilt the image for this run, the lookup had already returned 25.13.0, so the tag in your build will differ from ours too:

200 289371 bytes 0.370206s

The image is large, 2.96 GB on our host, because it carries a full Chrome and its dependencies. When an image of that size is a problem, or when you would rather not run Node.js at all, Gotenberg wraps the same Chromium engine in a ready-made HTTP API.

Puppeteer error index: exact messages and fixes

Every error below came from our lab runs, with the fix we applied.

error while loading shared libraries: libatk-1.0.so.0

Chrome for Testing was downloaded but the host lacks its libraries. Install them from the manifest that ships with the browser: apt-get satisfy with deb.deps on Ubuntu and Debian, or dnf with the filtered rpm.deps on Rocky Linux, as shown in the shared libraries section. Re-run ldd afterwards and keep going until it reports zero missing libraries.

FATAL: No usable sandbox! on Ubuntu 24.04 or 26.04

The AppArmor user namespace restriction on Ubuntu blocks the sandbox for any Chrome binary without a profile. Load the chrome-puppeteer AppArmor profile from this guide rather than adding --no-sandbox or setting kernel.apparmor_restrict_unprivileged_userns=0, both of which weaken isolation for untrusted HTML.

No usable sandbox! inside the Puppeteer Docker image

Running the container without extra capabilities produced the same fatal error, because the default Docker seccomp and AppArmor profiles do not allow the user namespace sandbox. Start the container with --cap-add=SYS_ADMIN as the Puppeteer Docker documentation specifies, and keep --init so the Chrome child processes are reaped when they exit.

npm WARN EBADENGINE Unsupported engine for puppeteer

The Node.js package in the distribution repository is older than the engine floor Puppeteer declares, which happened on Debian 13 (20.x) and would on Ubuntu 24.04 (18.x). Install the NodeSource LTS build and reinstall the project dependencies.

npm warn install-scripts … not covered by allowScripts

npm 11 warns when a dependency runs an install script that the project has not approved, and npm 12 blocks it, which leaves Puppeteer without a browser. Run npm install-scripts approve puppeteer (add --no-allow-scripts-pin to cover future versions) so the Chrome download stays part of a normal npm install.

DefaultProvider: Installing system dependencies requires root privileges

The full line is Error: All providers failed for chrome 154.0.8037.57: followed by this provider message, and it comes from npx puppeteer browsers install chrome --install-deps run as a normal user; we hit it on Ubuntu 24.04 during the benchmark. Skip the flag and run the apt-get satisfy command from this guide with sudo, which installs the same packages without running npm as root.

Error: Unable to find a match: (libcurl.so()(64bit) or …

dnf received the rich dependencies from rpm.deps split on spaces, which happens with $(...) command substitution. Pipe the filtered file through xargs -d '\n' so each line stays one argument.

Navigation timeout of 20000 ms exceeded

Under eight concurrent requests, one setContent() call waiting for networkidle0 hit the 20-second navigation timeout we set in the service, failing one request in twenty. Wait for load and an explicit readiness condition instead; after that change, every one of our 160 load-test requests succeeded.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) AI Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) Nextcloud vs ownCloud vs Seafile vs Syncthing vs OpenCloud Storage Nextcloud vs ownCloud vs Seafile vs Syncthing vs OpenCloud Best Panther Lake Laptops for Linux: Xe3, IPU7 Webcams and What Works Linux Tutorials Best Panther Lake Laptops for Linux: Xe3, IPU7 Webcams and What Works Configure AWS Application Load Balancer with CloudFormation Automation Configure AWS Application Load Balancer with CloudFormation

Leave a Comment

Press ESC to close