Guides

Migrating off wkhtmltopdf: a checklist that avoids the surprises

A practical sequence for replacing wkhtmltopdf in a production system: inventory, flag mapping, visual diff, cutover, rollback.

M Mikel Rougstone · 31 August 2026 · 4 min read
Migrating off wkhtmltopdf: a checklist that avoids the surprises

wkhtmltopdf is archived and its WebKit is old, but it is also embedded in systems that have printed invoices correctly for years. Replacing it is a migration, not a swap — and migrations go wrong when they are treated as swaps. Here is the sequence that works.

1. Inventory the call sites

Grep for wkhtmltopdf and for the wrappers that hide it: pdfkit (Python and Node), wicked_pdf (Rails), snappy and laravel-snappy (PHP), WkHtmlToPdf (.NET).

List every flag in use. Most systems use six or fewer:

grep -rn "wkhtmltopdf\|pdfkit\|wicked_pdf\|snappy" --include="*.py" --include="*.rb" \
     --include="*.php" --include="*.js" .

Write down which documents each call site produces. You will migrate them one type at a time, not all at once.

2. Map the flags

wkhtmltopdfModern equivalent
wkhtmltopdf in.html out.pdfPOST /api/v1/render with {"html": "…"}
--page-size A4"format": "A4"
--orientation Landscape"landscape": true
--margin-top 10mm (and the other three)"margin": "10mm" or {"top": "10mm", …}
--print-media-typealways on — print media is emulated
--zoom 0.8"scale": 0.8
--header-html header.html"header_html": "…"
--footer-html footer.html"footer_html": "Page {{page}} of {{pages}}"
--javascript-delay 2000"wait_for": "2000" or a CSS selector
--disable-smart-shrinkingnot needed; nothing shrinks
--no-background"print_background": false

The full table with notes lives on the wkhtmltopdf alternative page.

3. Expect the CSS to look better — and different

This is the step that surprises teams. Old WebKit ignored most of flexbox and all of grid, so your templates likely contain workarounds: tables used for layout, fixed pixel widths, floats everywhere, !important scattered to force something that a modern engine would have done correctly.

In a current engine those workarounds still render — but the CSS that was *silently dropped* now applies. A gap that did nothing suddenly adds space. A flex-wrap that was ignored starts wrapping.

Diff visually, page by page, on your five most common documents. Do not diff a fixture with two rows; diff the invoice with fourteen line items and a long customer address.

4. Fonts

wkhtmltopdf used the fonts installed on the rendering server. A modern renderer — hosted or your own container — has a different set.

If your template says font-family: "Segoe UI" and the server is Linux, there is no Segoe UI, and the fallback is whatever fontconfig picks. Either ship the font as a web font, or specify a fallback you actually like. Details and the CORS traps are in web fonts in generated PDFs.

5. Run both engines in parallel for a week

For every document generated, produce it with both engines and store the new one alongside the old. Compare a sample daily. This costs almost nothing and catches the template nobody remembered.

pdf_old = render_with_wkhtmltopdf(html)      # still the one you send
pdf_new = render_with_api(html)              # stored, not sent
store_for_review(order_id, pdf_old, pdf_new)

Reviewing twenty documents a day for a week finds the problems that a one-off test does not: the customer with a five-line address, the invoice with a page break in the totals, the locale that formats numbers differently.

6. Cut over behind a flag, per document type

A feature flag per document type lets you move invoices on Monday and contracts on Thursday, and flip back in seconds if a customer reports a problem.

if feature_enabled("pdf_v2", document_type="invoice"):
    pdf = render_with_api(html)
else:
    pdf = render_with_wkhtmltopdf(html)

Order matters: start with the highest-volume, lowest-risk document (usually receipts), finish with anything that carries legal weight (contracts, certificates).

7. Remove the binary

Only after the flag has been on for a full billing cycle — you want at least one month-end run through the new path. Then delete the Dockerfile lines, the apt packages, the fontconfig hacks and the --no-sandbox workarounds. That is the payoff: your image gets 400 MB smaller and stops shipping an unpatched rendering engine.

What usually goes wrong

  • Skipping step 5. Teams that test with fixtures and cut over discover the real templates in

production, on the day invoices go out.

  • Migrating everything at once. One bad document type blocks the whole rollout.
  • Forgetting headers and footers. wkhtmltopdf took separate HTML files; modern renderers take

templates with placeholders. [page] becomes {{page}}.

  • Assuming the old output was correct. Sometimes the wkhtmltopdf version was subtly broken —

wrong margins, clipped columns — and nobody noticed. The new engine makes it visible, and it reads as a regression when it is a fix.

Rollback plan

Keep it explicit and written down before you start:

1. Flip the feature flag off for the affected document type. 2. Regenerate the last N documents through the old path if any were sent wrong. 3. File what broke against the specific template, not the engine.

Migrations are safe when the way back is one config change. Ours took three weeks of parallel running across five document types and a single rollback — a certificate template that relied on a fixed pixel height and grew by four pixels in the new engine.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.