Every copy of Chrome already contains a complete HTML to PDF engine: the same print pipeline that runs when you press Ctrl+P, backed by the Blink layout engine and the Skia PDF writer. The --print-to-pdf flag exposes that pipeline to the shell, so a single command turns an HTML file or a URL into a PDF with no Node.js, no Python and no DevTools protocol code. For anyone replacing an old wkhtmltopdf call inside a shell script or a cron job, Chrome headless print to PDF is the closest one-line substitute that exists today.
This guide explains how the command-line printer actually decides what to render and when, which binary to install (full Google Chrome, the standalone chrome-headless-shell, or the snap Chromium that Ubuntu offers), and how page size, headers, footers, JavaScript waits, batch jobs and containers behave in practice. It is one of the tested tools in our wkhtmltopdf alternatives benchmark, where the Chrome command line produced the fastest cold renders of any engine we measured. The behavior described here was checked against Chrome 155 in October 2026 on Ubuntu 26.04, Ubuntu 24.04, Debian 13 and Rocky Linux 10 with SELinux enforcing.
What happens during a Chrome headless print to PDF
When you pass --headless --print-to-pdf, Chrome starts a browser without a window, loads the target page, waits for the page load event, switches the document into print media (so @media print rules and @page rules apply), lays the content out into pages and hands the result to Skia, which writes a tagged PDF with embedded and subsetted fonts. The key insight is that the command line has no notion of “wait until my chart has drawn”; it only knows the load event plus an optional fast-forward of timers, which is why JavaScript-heavy pages need the flags described later.
Two different binaries can do this job, and the difference matters more than most guides admit. The full google-chrome package runs the current headless mode, which is the real browser with its UI removed. The older, lighter headless implementation now ships only as a separate download called chrome-headless-shell from the Chrome for Testing project. Passing --headless=old to the full browser no longer selects it; in our runs --headless=old, --headless=new and plain --headless produced PDFs of the same size with identical extracted text. We timed both binaries on the same four-page invoice, seven interleaved rounds each. The 4 vCPU VM was also running other lab jobs at the time (load average around 6.7), so read the ratios rather than the absolute times; the gap is large either way:
| Binary and mode | Median time for one invoice | Sandbox on Ubuntu | Updates |
|---|---|---|---|
| chrome-headless-shell | 0.32 s | Needs an AppArmor profile | Manual download |
google-chrome with a fixed --user-data-dir | 0.72 s | Works (profile shipped by Ubuntu) | apt or dnf repository |
google-chrome with a fresh --user-data-dir per run | 0.76 s | Works | apt or dnf repository |
| google-chrome with its default profile | 1.10 s | Works | apt or dnf repository |
| snap Chromium | Not timed (confinement problems) | Works inside snap confinement | snapd |
Our wider benchmark measured chrome-headless-shell at 0.20 to 0.29 seconds per document with roughly 77 to 95 MiB of working memory, which is quicker than WeasyPrint and the Puppeteer or Playwright wrappers because nothing else has to start before the browser. Google Chrome is the easier install and receives security updates through the package manager, so a sensible pattern is Google Chrome for occasional conversions and chrome-headless-shell for batch jobs.
Preparing a small test workspace
Every example below writes its PDFs to ~/pdf and reads its sample pages from ~/docs, and the checks use the poppler tools (pdftotext, pdfinfo, pdffonts) to prove what ended up inside each PDF. Install those tools along with curl, jq and unzip, which the chrome-headless-shell download needs. On Ubuntu and Debian:
sudo apt install -y poppler-utils curl jq unzip
On Rocky Linux, AlmaLinux and RHEL the package names are the same:
sudo dnf -y install poppler-utils curl jq unzip
Create the two directories and a one-line test page that has no CSS at all, which is the clearest way to see what Chrome does by default:
mkdir -p ~/pdf ~/docs
printf '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Plain page</title></head><body><h1>Hello PDF</h1><p>No @page rules here.</p></body></html>\n' > ~/docs/plain.html
The later sections add a few more small pages to ~/docs as they need them. The benchmark figures quoted in this guide came from heavier documents (a four-page invoice with a web font, an inline SVG logo and a 140-row table), so wherever an example mentions an invoice, substitute an HTML file of your own.
Installing Google Chrome for headless printing
Google publishes the same stable .deb for Ubuntu 26.04, Ubuntu 24.04, Debian 13 and Debian 12. Download it and give apt the full path, which tells apt to install a local file and resolve the libraries it needs from the distribution repositories:
wget -O ~/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install -y ~/google-chrome.deb
The package also registers the Google apt repository so that Chrome security fixes arrive with your normal apt upgrade. On both Ubuntu releases and on Debian 13 it is written as a deb822 file:
google-chrome --version
head -6 /etc/apt/sources.list.d/google-chrome.sources
The version line and the start of the repository definition look like this on Ubuntu 26.04:
Google Chrome 155.0.8059.39
### THIS FILE IS AUTOMATICALLY CONFIGURED ###
# Changes to this file will not be preserved.
# This file will not be recreated if removed.
X-Repolib-Name: Google Chrome
Types: deb
URIs: https://dl.google.com/linux/chrome-stable/deb/
Debian 12 was checked in a debian:12 container rather than a full VM: the same package installed cleanly, reported the same version and printed the same four-page A4 test invoice. On Rocky Linux 10, dnf installs the RPM straight from Google and creates /etc/yum.repos.d/google-chrome.repo for future updates. We tested Rocky Linux 10.2; AlmaLinux 10 and RHEL 10 take the same RPM:
sudo dnf -y install https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
google-chrome --version
getenforce
SELinux stays in enforcing mode, and headless printing needed no policy change. After a run of test prints, ausearch found no denials:
Google Chrome 155.0.8059.39
Enforcing
Confirm the absence of AVC denials on your own host after your first conversion:
sudo ausearch -m avc -ts recent
An empty result prints as <no matches>. If you prefer a guided install with the repository steps spelled out, our separate guides cover Google Chrome on Ubuntu and Google Chrome on Rocky Linux and AlmaLinux.
Installing chrome-headless-shell for batch work
Chrome for Testing publishes a JSON index of the current stable build with a direct download link for chrome-headless-shell, which means the install can always fetch whatever is current without a hardcoded version. The commands use the curl, jq and unzip installed earlier, and the mkdir is there because the Debian 13 cloud image we tested had no /usr/local/bin directory at all, so the symlink step failed with “No such file or directory” until we created it:
CHS_URL=$(curl -s https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json | jq -r '.channels.Stable.downloads["chrome-headless-shell"][] | select(.platform=="linux64") | .url')
echo "${CHS_URL}"
curl -sLo /tmp/chrome-headless-shell-linux64.zip "${CHS_URL}"
sudo unzip -oq /tmp/chrome-headless-shell-linux64.zip -d /opt
sudo mkdir -p /usr/local/bin
sudo ln -sf /opt/chrome-headless-shell-linux64/chrome-headless-shell /usr/local/bin/chrome-headless-shell
chrome-headless-shell --version
The echoed URL shows which build the index resolved to, followed by the version string of the binary itself:
https://storage.googleapis.com/chrome-for-testing-public/155.0.8059.39/linux64/chrome-headless-shell-linux64.zip
Google Chrome for Testing 155.0.8059.39
Unlike the packaged browser, this zip doesn’t pull in any shared libraries. If Google Chrome is already installed on the host, those libraries are present and nothing else is needed. On a host without it, we derived the missing libraries with ldd in clean debian:13, ubuntu:26.04 and ubuntu:24.04 containers, and this list left zero unresolved libraries in all three (the font package matters too, because a host with no fonts produces PDFs without readable text):
sudo apt install -y --no-install-recommends libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libdbus-1-3 libexpat1 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 fonts-dejavu-core
Debian 12 doesn’t use the t64 package names, so on that release the simplest route is installing the Google Chrome package, which brings the right libraries along. On Rocky Linux 10 the binary first failed with error while loading shared libraries: libatk-1.0.so.0; this set of packages fixed it:
sudo dnf -y install nss atk at-spi2-atk cups-libs libdrm libxkbcommon libXcomposite libXdamage libXrandr libXfixes mesa-libgbm pango cairo alsa-lib
Error: “No usable sandbox!” on Ubuntu 26.04 and 24.04
On Debian 13 and Rocky Linux 10 the freshly unpacked binary printed immediately. On both Ubuntu releases it aborted before loading anything:
[1008/102330.865098:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:130] 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.
What is actually happening is that Ubuntu sets kernel.apparmor_restrict_unprivileged_userns = 1, so a program may only create the user namespaces that the Chrome sandbox depends on if an AppArmor profile grants it the userns permission. Ubuntu ships such a profile, named chrome, for /opt/google/chrome/chrome, and that is the reason the packaged browser works; Ubuntu 26.04 also ships a chromium profile for its own Chromium path. Neither attaches to a binary you unzipped into /opt/chrome-headless-shell-linux64. The correct fix is a small profile for that one path, not --no-sandbox, which would remove the main barrier between untrusted HTML and your server. Create the profile:
sudo vim /etc/apparmor.d/chrome-headless-shell-pdf
Add this profile, which grants user namespaces to the headless shell binary only. This step and the next one are for Ubuntu only; on Debian 13 and Rocky Linux 10 the binary prints without a profile, so skip ahead to the test print:
abi <abi/4.0>,
include <tunables/global>
profile chrome-headless-shell-pdf /opt/chrome-headless-shell-linux64/chrome-headless-shell flags=(unconfined) {
userns,
include if exists <local/chrome-headless-shell-pdf>
}
On Ubuntu, load it and confirm AppArmor lists it. The profile attaches to the real path under /opt, so calling the binary through the /usr/local/bin symlink still matches:
sudo apparmor_parser -r /etc/apparmor.d/chrome-headless-shell-pdf
sudo aa-status | grep chrome-headless-shell-pdf
AppArmor reports the profile as loaded:
chrome-headless-shell-pdf
Now run the test print, which is the same command on every distribution:
chrome-headless-shell --no-pdf-header-footer --print-to-pdf=$HOME/pdf/chs-plain.pdf file://$HOME/docs/plain.html
On Ubuntu the print that crashed a moment earlier now succeeds, on 26.04 and on 24.04:
13880 bytes written to file /home/gedion/pdf/chs-plain.pdf
Why snap Chromium is a poor fit for file conversion
On Ubuntu 26.04 the chromium name has no apt candidate at all, and chromium-browser is a transitional package that installs the snap (the same result as sudo snap install chromium). The snap prints PDFs correctly as long as every path involved sits inside your home directory. Strict confinement breaks the paths that batch jobs usually use. When we asked it to write /tmp/snap-out.pdf, it printed the reassuring line below, yet no file appeared in /tmp:
13993 bytes written to file /tmp/snap-out.pdf
ls: cannot access '/tmp/snap-out.pdf': No such file or directory
The snap has a private /tmp, and the PDF was actually sitting at /tmp/snap-private-tmp/snap.chromium/tmp/snap-out.pdf. Reading an input from /tmp failed for the same reason, and writing into /srv failed outright (the lines below are trimmed of their process ID and timestamp prefix):
ERROR:components/headless/command_handler/headless_command_handler.cc:403] Page load failed: net::ERR_FILE_NOT_FOUND
ERROR:components/headless/command_handler/headless_command_handler.cc:266] Failed to write file /srv/pdf/snap-srv.pdf: No such file or directory (2)
Those failures are confinement doing its job, but they make the snap a trap for services and cron jobs that keep their files in /srv, /var/lib or /tmp. On Debian, the native chromium package behaves like a normal binary; our Chromium install guide covers both sources if you need Chromium specifically rather than Google Chrome.
What the default PDF looks like and why
The smallest useful conversion takes an input URL and an output path. Note that local files must be given as file:// URLs with an absolute path, because Chrome treats the positional argument as a URL rather than a filename:
google-chrome --headless --print-to-pdf=$HOME/pdf/default.pdf file://$HOME/docs/plain.html
Chrome reports the byte count when it succeeds. The D-Bus line above it appears on every server without a desktop session; it refers to the UPower battery service, and it is harmless noise rather than a failure:
[94384:94384:1008/101902.369274:ERROR:dbus/object_proxy.cc:572] Failed to call method: org.freedesktop.DBus.Properties.GetAll: object_path= /org/freedesktop/UPower/devices/DisplayDevice: org.freedesktop.DBus.Error.ServiceUnknown: The name org.freedesktop.UPower was not provided by any .service files
21837 bytes written to file /home/gedion/pdf/default.pdf
Two defaults surprise people who come from wkhtmltopdf. First, a page without an @page rule is printed on US Letter, not A4. Second, Chrome adds the browser print header and footer: the date and document title at the top, and the full source URL and page counter at the bottom. Extract the text and the page size of the PDF you just made:
pdftotext -layout ~/pdf/default.pdf - | grep -v '^\s*$'
pdfinfo ~/pdf/default.pdf | grep -E "Pages|Page size"
Both additions sit around the two lines of real content, on a Letter page:
10/8/26, 1:44 PM Plain page
Hello PDF
No @page rules here.
file:///home/gedion/docs/plain.html 1/1
Pages: 1
Page size: 612 x 792 pts (letter)
Leaking the absolute path of the source file into a customer invoice is rarely what you want, so --no-pdf-header-footer belongs on nearly every invocation. The older --print-to-pdf-no-header spelling still appears in many answers online, but Chrome now ignores it: in our test its output text was identical to the default run, header and footer included. Use the current flag:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/plain-nohf.pdf file://$HOME/docs/plain.html
pdftotext -layout ~/pdf/plain-nohf.pdf - | grep -v '^\s*$'
Only the document content remains:
Hello PDF
No @page rules here.
If --print-to-pdf is given without a path, Chrome writes output.pdf into the current directory. Background colors and images are printed by the command-line printer even when the stylesheet does not set print-color-adjust: exact, which differs from the printBackground: false default of the DevTools API that Puppeteer and Playwright expose.
Why page size and margins belong in CSS
The Chrome command line has no flag for paper size, orientation or margins, and this is deliberate: those settings are part of the document in CSS Paged Media, and Chrome honors them from the stylesheet. Recent Chrome releases also implement @page margin boxes, so a “Page 1 of 4” footer that needed --footer-right "[page] of [topage]" in wkhtmltopdf now lives in the HTML itself. Create a small test document to see this working:
vim ~/docs/page-setup.html
Paste this page, which asks for A4 landscape, custom margins, a page counter in the bottom-right margin box and a forced page break:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Page setup test</title>
<style>
@page { size: A4 landscape; margin: 12mm 15mm; }
@page { @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; } }
body { font-family: "DejaVu Sans", sans-serif; }
.banner { background: #0b5cad; color: #fff; padding: 12px; }
.banner-exact { background: #0b5cad; color: #fff; padding: 12px; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
h2 { break-before: page; }
</style>
</head>
<body>
<div class="banner">Banner without print-color-adjust</div>
<p>First page.</p>
<div class="banner-exact">Banner with print-color-adjust: exact</div>
<h2>Second page</h2>
<p>Forced onto a new page by break-before.</p>
</body>
</html>
Print it and inspect the page geometry and the margin-box text with the poppler tools:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/page-setup.pdf file://$HOME/docs/page-setup.html
pdfinfo -f 1 -l 2 ~/pdf/page-setup.pdf | grep -E "^Pages|size"
pdftotext -layout ~/pdf/page-setup.pdf - | grep -E "Page [0-9] of"
Both pages come out as A4 landscape (841.92 points wide), and the counter resolves the total page count:
22352 bytes written to file /home/gedion/pdf/page-setup.pdf
Pages: 2
Page 1 size: 841.92 x 594.96 pts (A4)
Page 2 size: 841.92 x 594.96 pts (A4)
Page 1 of 2
Page 2 of 2
Both blue banners were rendered in color, which confirms the background behavior described above. Our feature probes found the limits of the paged media support in Chrome: margin boxes, counter(pages), named pages that switch one section to landscape, CSS grid, flexbox, multi-column text, :has() and native CSS nesting all worked, while string-set running headers (a “Chapter: Summary” header that follows the current heading), target-counter() page numbers in a table of contents and float: footnote did not. If your documents depend on those three, a print-focused engine is the better tool, and our WeasyPrint guide shows them working.
How Chrome decides the page is ready to print
Chrome prints as soon as the load event has fired and the first layout is complete. Anything a script schedules for later, such as a chart library that draws after a setTimeout or a fetch that fills a table, is simply not there yet. A small test page makes the behavior easy to see. Create it:
vim ~/docs/dashboard.html
Paste this page, which writes JS_RENDERED_OK immediately, writes JS_MODERN_OK through ES2020 optional chaining, and adds JS_DELAYED_OK from a 500 ms timer:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Timer test</title>
</head>
<body>
<h1>Dashboard</h1>
<div id="status"></div>
<script>
const st = document.getElementById('status');
st.innerHTML += '<p>JS_RENDERED_OK</p>';
const cfg = { render: { modern: true } };
if (cfg?.render?.modern) st.innerHTML += '<p>JS_MODERN_OK</p>';
setTimeout(() => { st.innerHTML += '<p>JS_DELAYED_OK</p>'; }, 500);
</script>
</body>
</html>
Print it once with no wait flag and once with a one-second virtual time budget:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/dash-nobudget.pdf file://$HOME/docs/dashboard.html
google-chrome --headless --no-pdf-header-footer --virtual-time-budget=1000 --print-to-pdf=$HOME/pdf/dash-vtb.pdf file://$HOME/docs/dashboard.html
Then list the markers that actually landed in each PDF:
for f in dash-nobudget dash-vtb; do echo "$f: $(pdftotext ~/pdf/$f.pdf - | grep -oE 'JS_[A-Z]+_OK' | tr '\n' ' ')"; done
Only the budgeted run caught the timer:
dash-nobudget: JS_RENDERED_OK JS_MODERN_OK
dash-vtb: JS_RENDERED_OK JS_MODERN_OK JS_DELAYED_OK
Repeating the test with the other timing flags gives the full picture:
| Flags | JS_RENDERED_OK | JS_MODERN_OK | JS_DELAYED_OK (500 ms timer) |
|---|---|---|---|
| No wait flag | yes | yes | missing |
--virtual-time-budget=300 | yes | yes | missing |
--virtual-time-budget=1000 | yes | yes | yes |
--virtual-time-budget=1000 --run-all-compositor-stages-before-draw | yes | yes | yes |
--timeout=5000 | yes | yes | missing |
The mechanism here is worth understanding, because the two timing flags are often confused. --virtual-time-budget runs the page on a virtual clock and fast-forwards timers until that much virtual time has elapsed, so a 1000 ms budget lets a 500 ms timer fire without the conversion taking an extra real second; a budget shorter than the timer misses it. --timeout, by contrast, is only an upper limit on how long Chrome waits for the page to load, and it never makes Chrome wait longer, so a 5000 ms timeout still missed the delayed marker. Set it too low and the result is worse than missing content. A 50 ms timeout against a remote page shows why:
google-chrome --headless --no-pdf-header-footer --timeout=50 --print-to-pdf=$HOME/pdf/to.pdf https://example.com/
echo "exit=$? size=$(stat -c %s ~/pdf/to.pdf) text_chars=$(pdftotext ~/pdf/to.pdf - | tr -d '[:space:]' | wc -c)"
Chrome logs the timeout, then still writes a file and exits with status 0. Our lab hit the same result at 50, 100 and 200 ms; on a faster network the page may finish loading inside the window, in which case you get a normal PDF, so lower the value until you see the error:
[224998:224998:1008/134426.775690:ERROR:components/headless/command_handler/headless_command_handler.cc:409] Page load timed out.
848 bytes written to file /home/gedion/pdf/to.pdf
exit=0 size=848 text_chars=0
That 848-byte file is a blank page, which passes both an exit code check and a “file is not empty” check, so a script should confirm that the PDF contains text, as the batch script below does with pdftotext. When a page needs a real readiness signal (wait until a selector exists, until a network request finishes, or until a variable is set), the command line cannot express it, and a few lines of Puppeteer or Playwright code driving the same browser is the right step up.
Converting a URL instead of a local file
Any URL Chrome can load works as input, including pages served by your own application. To try it without an application, serve ~/docs with the web server built into Python, bound to localhost:
python3 -m http.server 8090 --bind 127.0.0.1 --directory ~/docs > /tmp/docs-http.log 2>&1 &
Print the page setup example over HTTP, plus a public page:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/page-setup-http.pdf http://127.0.0.1:8090/page-setup.html
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/example.pdf https://example.com/
pdftotext ~/pdf/example.pdf - | head -1
The HTTP copy comes out at exactly the same size as the file:// print earlier, and the text of the public page is present:
22352 bytes written to file /home/gedion/pdf/page-setup-http.pdf
34644 bytes written to file /home/gedion/pdf/example.pdf
This domain is for use in documentation examples
Leave the server running for the security test below. Serving HTML over HTTP has a second benefit, because a page loaded from http:// cannot open file:// resources.
Batch conversion with parallel jobs
A folder of generated invoices is the classic wkhtmltopdf workload, and it maps cleanly onto one Chrome process per file run in parallel with xargs -P. Each job gets its own throwaway profile directory, so parallel processes never contend for a profile lock and nothing accumulates between runs. Each output must also contain extractable text, because Chrome can exit successfully with a blank page, and any failed file makes the whole script exit non-zero so cron or CI notices. Create the script:
sudo vim /usr/local/bin/html2pdf-batch.sh
Add the following. CHROME selects the binary and BUDGET the virtual time budget, and both can be overridden from the environment:
#!/usr/bin/env bash
# Convert every .html file in SRC to a PDF in DST with chrome-headless-shell.
# Usage: html2pdf-batch.sh SRC DST [JOBS]
set -euo pipefail
SRC=$(realpath "${1:?source directory}")
DST=$(realpath -m "${2:?output directory}")
JOBS=${3:-$(nproc)}
CHROME=${CHROME:-chrome-headless-shell}
BUDGET=${BUDGET:-2000}
mkdir -p "$DST"
export DST CHROME BUDGET
render() {
local in=$1 name out profile rc=0
name=$(basename "${in%.html}")
out="$DST/$name.pdf"
profile=$(mktemp -d)
rm -f "$out"
timeout 60 "$CHROME" --headless --no-pdf-header-footer --virtual-time-budget="$BUDGET" \
--user-data-dir="$profile" --print-to-pdf="$out" "file://$in" >/dev/null 2>&1 || rc=$?
rm -rf "$profile"
# Chrome can exit 0 with a blank page after a load timeout, so require extractable text too
if [ "$rc" -eq 0 ] && [ -s "$out" ] && [ -n "$(pdftotext "$out" - 2>/dev/null | tr -d '[:space:]')" ]; then
echo "ok $name"
else
echo "FAIL $name (exit $rc)" >&2
return 1
fi
}
export -f render
find "$SRC" -maxdepth 1 -name '*.html' -print0 | xargs -0 -r -n1 -P "$JOBS" bash -c 'render "$1"' _
The --headless flag is redundant for chrome-headless-shell but required for Google Chrome; without it, our first run of the script with CHROME=google-chrome failed on all 40 files because the full browser tried to open a window. Make the script executable, put the HTML files you want to convert into ~/reports, and run it with four parallel jobs. To try it with the sample pages from this guide, copy three of them in (leave out security.html, which exists to leak files):
mkdir -p ~/reports && cp ~/docs/{plain,page-setup,dashboard}.html ~/reports/
Then run the batch. Our captured run below used 40 copies of the four-page invoice instead, so your output lists your own file names:
sudo chmod 0755 /usr/local/bin/html2pdf-batch.sh
html2pdf-batch.sh ~/reports ~/pdf-out 4
echo "exit=$?"
Each finished file prints one line, in completion order rather than alphabetical order because the jobs run concurrently, and the exit status follows the last of the 40 lines (the middle 37 are trimmed here):
ok invoice-17
ok invoice-20
ok invoice-19
exit=0
We converted 40 multi-page invoices (each with a web font, an inline SVG logo and a 140-row table) on a 4 vCPU VM with each binary at three levels of parallelism. Every run converted all 40 files, 240 conversions in total:
| Binary | 1 job | 2 jobs | 4 jobs |
|---|---|---|---|
| chrome-headless-shell | 12.9 s | 7.7 s | 5.8 s |
| google-chrome | 28.5 s | 20.2 s | 19.7 s |
The headless shell finished the batch in under half the time of full Chrome even with a single job, and it kept scaling with parallelism, while full Chrome barely improved beyond two jobs because each instance starts considerably more machinery. For a few documents a day the difference is irrelevant; for thousands of invoices at month end it decides whether the job finishes in minutes or in an hour.
The failure path was tested as well. With one good file and one HTML page whose body is empty, the script printed FAIL empty (exit 0) next to ok invoice-01 and exited with status 123, the code xargs returns when any job fails. The text check also rejects pages that are legitimately blank, so drop the pdftotext test if you print image-only documents.
Running the converter in a container
A container is a natural home for a PDF converter that handles HTML you do not fully control, because it bounds what a compromised renderer can reach. We started with the popular chromedp/headless-shell image, but it is built for DevTools protocol clients: its entrypoint starts a remote debugging server, and even with the entrypoint overridden to the bundled binary, our --print-to-pdf runs hung for 90 seconds without producing a file. Building a small image around chrome-headless-shell worked first time. Create a build directory and a Dockerfile (Docker itself is covered in our Docker CE install guide):
mkdir -p ~/chs-docker
vim ~/chs-docker/Dockerfile
Add this Dockerfile, which installs the libraries listed earlier, downloads the current stable headless shell through the same JSON index, removes the download tools and runs as an unprivileged user. The explicit fontconfig package matters: our first build left it out, the cleanup step removed it as an orphan, and every render then printed Fontconfig error: Cannot load default config file: File not found:
FROM debian:13-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl jq unzip \
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libdbus-1-3 \
libexpat1 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libx11-6 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 fontconfig fonts-dejavu-core fonts-liberation \
&& CHS_URL=$(curl -s https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json \
| jq -r '.channels.Stable.downloads["chrome-headless-shell"][] | select(.platform=="linux64") | .url') \
&& curl -sLo /tmp/chs.zip "${CHS_URL}" \
&& unzip -q /tmp/chs.zip -d /opt \
&& rm -f /tmp/chs.zip \
&& apt-get purge -y curl jq unzip && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 10001 pdf
USER pdf
WORKDIR /work
ENTRYPOINT ["/opt/chrome-headless-shell-linux64/chrome-headless-shell", "--no-pdf-header-footer"]
Build the image and check its size:
cd ~/chs-docker
sudo docker build -t chs-pdf .
sudo docker image inspect chs-pdf --format "{{.Size}}" | numfmt --to=iec
The finished image reported this size, which is mostly the browser itself plus its graphics and font libraries:
804M
Even as a non-root user, the browser sandbox could not start inside the container on our Ubuntu 26.04 Docker host (the same “No usable sandbox!” error), because the default Docker seccomp and AppArmor settings block the namespaces it needs. Running as root isn’t an option either; the binary refuses with Running as root without --no-sandbox is not supported. The practical answer inside a container is --no-sandbox, with the container providing the isolation instead, and the most valuable addition is --network none when the documents do not need to fetch anything over the network:
mkdir -p ~/work
cp ~/docs/page-setup.html ~/work/
sudo chown -R 10001:10001 ~/work
sudo docker run --rm --network none -v ~/work:/work chs-pdf --no-sandbox --print-to-pdf=/work/page-setup.pdf file:///work/page-setup.html
pdfinfo ~/work/page-setup.pdf | grep -E "Pages|Page size"
The chown hands the work directory to UID 10001, the pdf user inside the image, so the container can write its output without the directory being opened to everyone. We ran the container on Ubuntu only; on an SELinux host such as Rocky Linux, Docker and Podman bind mounts normally need a relabel suffix (-v ~/work:/work:Z), which we did not test here. The container produced the same two landscape A4 pages as the host:
22247 bytes written to file /work/page-setup.pdf
Pages: 2
Page size: 841.92 x 594.96 pts (A4)
What a print job can reach on your server
A browser that renders HTML will follow every reference in that HTML, and this matters as soon as any part of the document comes from users. Create a probe page that plays the part of hostile input:
vim ~/docs/security.html
It embeds the password file in an iframe and requests an image from a localhost port:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Security probe</title>
</head>
<body>
<p>Untrusted content starts here.</p>
<iframe src="file:///etc/passwd" width="600" height="200"></iframe>
<img src="http://127.0.0.1:8099/ssrf-probe.png" alt="">
</body>
</html>
Loaded as a file:// URL, the page pulls the password file straight into the PDF:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/sec.pdf file://$HOME/docs/security.html
pdftotext ~/pdf/sec.pdf - | grep -m1 "root:x:0:0"
The first line of /etc/passwd from the host is now inside the document:
17021 bytes written to file /home/gedion/pdf/sec.pdf
root:x:0:0:root:/root:/bin/bash
Print the same page through the web server started in the URL section, then stop the server:
google-chrome --headless --no-pdf-header-footer --print-to-pdf=$HOME/pdf/sec-http.pdf http://127.0.0.1:8090/security.html
pdftotext ~/pdf/sec-http.pdf - | grep -c "root:x:0:0"
kill %1
Served from an http:// origin, the iframe could not open the local file, so nothing leaked:
8206 bytes written to file /home/gedion/pdf/sec-http.pdf
0
In our wider benchmark the localhost resources were requested too, which is the server-side request forgery pattern that made the last wkhtmltopdf release a critical CVE. Three habits close most of this. Serve generated HTML over HTTP from your application rather than writing it to disk and printing a file:// URL, since an http:// origin cannot open local files. Keep the sandbox enabled on hosts, using the AppArmor profile above instead of --no-sandbox. Render untrusted HTML in a container started with --network none: in our container test the probe image request reached a listener on the host once over the host network and zero times with networking disabled, and the only password file it could read was the one inside the container.
Adding a bookmark outline to long PDFs
Chrome tags its PDFs for accessibility by default (pdfinfo reports Tagged: yes), but it doesn’t create the bookmark panel that readers use to jump between sections unless you ask for it. The --generate-pdf-document-outline flag builds that outline from the headings in the document, and it replaces the --outline option of wkhtmltopdf.
Create a short document with a title and three sections, each starting on a new page, then print it with the outline flag:
printf '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Quarterly report</title><style>h2 { break-before: page; }</style></head><body><h1>Quarterly report</h1><h2>Summary</h2><p>One.</p><h2>Capacity</h2><p>Two.</p><h2>Incidents</h2><p>Three.</p></body></html>\n' > ~/docs/outline.html
google-chrome --headless --no-pdf-header-footer --generate-pdf-document-outline --print-to-pdf=$HOME/pdf/outline.pdf file://$HOME/docs/outline.html
poppler does not display outlines, so we read the tree with qpdf. On Ubuntu and Debian it is in the main repositories:
sudo apt install -y qpdf
On Rocky Linux 10, qpdf lives in the CRB repository, so enable it first:
sudo dnf config-manager --set-enabled crb
sudo dnf -y install qpdf
Then walk the nested outline entries with jq:
qpdf --json --json-key=outlines ~/pdf/outline.pdf | jq -r 'def walk_o(d): .title as $t | "\(" "*d)\($t) -> page \(.destpageposfrom1)", (.kids[]? | walk_o(d+1)); .outlines[] | walk_o(0)'
Each heading became a bookmark pointing at the right page:
Quarterly report -> page 1
Summary -> page 2
Capacity -> page 3
Incidents -> page 4
Flag reference card
Most wkhtmltopdf options have a direct replacement, but several move from the command line into CSS, and a few have no equivalent in Chrome at all. This table maps the options we see most often in old scripts, and our wkhtmltopdf install guide remains available for systems that cannot migrate yet:
| wkhtmltopdf option | Chrome command-line equivalent |
|---|---|
-s A4 / --page-size | @page { size: A4; } in CSS (Chrome defaults to Letter) |
-O Landscape | @page { size: A4 landscape; }, or a named page for one section |
-T -B -L -R margins | @page { margin: 12mm 15mm; } |
--footer-right "[page] of [topage]" | @page { @bottom-right { content: "Page " counter(page) " of " counter(pages); } } |
--header-html / --footer-html | No CLI flag; use margin boxes, or headerTemplate in Puppeteer or Playwright |
| Default header and footer off | --no-pdf-header-footer (Chrome adds them by default) |
--javascript-delay 1000 | --virtual-time-budget=1000 |
--print-media-type | Default behavior, print CSS always applies |
--no-background | No flag; backgrounds print by default, remove them in @media print |
--outline | --generate-pdf-document-outline |
--toc | No equivalent (target-counter() unsupported) |
--enable-local-file-access | Not needed for file:// pages, which can already read local files |
-q | Redirect stderr, Chrome has no quiet flag |
The Chrome flags themselves behaved as follows in our tests, which is worth keeping next to any script that uses them:
| Flag | Observed behavior |
|---|---|
--headless | Required for google-chrome, harmless for chrome-headless-shell; =old and =new gave the same size and text |
--print-to-pdf[=path] | Writes output.pdf in the current directory when no path is given |
--no-pdf-header-footer | Removes the date, title, URL and page counter that Chrome adds |
--print-to-pdf-no-header | Ignored; output identical to the default |
--virtual-time-budget=ms | Fast-forwards timers; must exceed the longest timer you need |
--timeout=ms | Upper limit only; too low gives “Page load timed out.” and a blank 848-byte PDF with exit code 0 |
--run-all-compositor-stages-before-draw | Accepted; no visible change on our documents |
--user-data-dir=dir | Isolates parallel jobs; fixed and fresh directories both started faster than the default profile |
--generate-pdf-document-outline | Adds bookmarks from headings |
--no-sandbox | Needed only inside containers or as root; never on a host rendering untrusted HTML |
With those mappings, most single-line wkhtmltopdf calls translate into a single Chrome line plus a few lines of @page CSS, and the remaining gaps (running headers, tables of contents with page numbers, footnotes) point clearly at the engines compared in the full alternatives benchmark.