Skip to content
All writing

wkhtmltopdf is dead. I kept using it anyway.

August 9, 2026PDFGoChromiumOpen Source

There’s a binary on one of my servers that hasn’t shipped a release since 2020, whose project was archived in 2023, and which I have no intention of removing. It’s called wkhtmltopdf, and it turns HTML into PDFs. Payslips, invoices, reports — the boring documents a business actually runs on. It has done that job, without complaint, for years.

The thing about a dependency like that is you forget it’s there. Nobody puts “HTML to PDF” on a roadmap. It lives in the basement of the stack, doing its one job, until the day a designer ships a stylesheet with a container query in it and the PDF comes out looking like it was rendered in 2011.

Which, it turns out, it was.

wkhtmltopdf renders with a patched Qt 4.8, and the browser engine bolted inside that is a build of WebKit from roughly fourteen years ago. No CSS custom properties. No working calc(). No Grid. It will never render @layer or a container query, because those need a browser from 2022 or later, and this one is a fossil. It also carries a couple of unpatched security holes, one of them scored a cheerful 9.8. This is not a tool anyone should be excited about in 2026.

So you migrate. That’s the advice. It’s good advice. The modern options are genuinely excellent — headless Chrome, WeasyPrint, Gotenberg — and if you’re starting something new, go use one of them and don’t look back.

But I wasn’t starting something new. I had a working system, and “working” is doing a lot of load-bearing work in that sentence. Years of production had taught wkhtmltopdf a private language: --header-html, --footer-center "[page] of [topage]", cover, toc, a couple dozen other switches, all wired into calling code that simply shells out to a path and trusts the result. Migrating isn’t “swap the library.” It’s ripping out the PDF layer of an application nobody has opened since 2019, re-testing every template, and praying you didn’t miss the one report that only generates on the last day of the quarter.

That’s a lot of risk to take on. And for what? The tool isn’t broken. The engine is broken. The command line — the part that’s threaded through everything — works fine. Once you see it that way, the whole problem inverts. You don’t want to replace wkhtmltopdf. You want to replace the one part of it that rotted, and leave everything else exactly where it is.

So that became the rule, before I wrote any code: it has to be a real drop-in. Same command-line grammar, so nothing that calls it has to change. The same binary at /usr/local/bin/wkhtmltopdf, because the calling code should never know anything happened. One installable file — no container, no daemon, no sidecar. Gotenberg is a lovely piece of engineering, but a background HTTP service is a new moving part, a new network hop, a new thing to page you at 3am. For a system that today calls a local binary, that’s not minimum impact. That’s a new dependency in a trenchcoat.

The engine had to be Chromium. I looked at the alternatives so you don’t have to: the maintained WebKit fork froze on a 2016 engine and still can’t do the things I needed, and Chromium-inside-Qt has the CSS but deliberately drops the header, footer, and outline hooks wkhtmltopdf is built on — a gap that’s sat open in Qt’s tracker since 2017, with nine years of people asking and nobody delivering. Dead ends, both. The honest path was to drive headless Chromium directly, over the DevTools Protocol, from a small Go program that speaks wkhtmltopdf’s arguments on one end and Chromium on the other, with pdfcpu stitching the pages together.

Most of the switches were easy. Paper size, margins, zoom, cookies, JavaScript timing — Chromium has an answer for all of them. The one that fought me was headers and footers.

Chromium has a built-in header and footer feature, and it is a trap. It runs in a cramped little sandbox that can’t load your stylesheets and ignores absolute positioning. Hand it a real footer — one with a <base href>, a linked CSS bundle, and position: absolute; bottom: 0 — and you get naked text shoved into a corner. I shipped exactly that on the first pass, looked at the result, and knew it was wrong.

The fix was to stop being clever and go read what wkhtmltopdf actually did. It never used a template mechanism at all. It rendered your header and footer as full pages, with their real CSS, and stamped them onto each sheet. So now I do the same: render the footer as its own complete page, let its stylesheets load like they would in any browser, make its background transparent, and composite it over every content page. The footer lands where the CSS says it should, because it’s finally being rendered by the same kind of engine the author was looking at when they wrote it.

And then something nice happened, the way it sometimes does when you stop fighting the design. Because the compositing runs over the whole merged document instead of each piece, page numbers now count continuously across the cover, the table of contents, and the body — the exact thing the lazy approach gets wrong. I didn’t build that feature. It fell out of building the other one correctly.

The result is bilihtmltopdf: a single Go binary that answers to wkhtmltopdf. Same command line, same headers and footers, cover pages, table of contents, outlines, links. It renders with today’s Chromium, so @layer and container queries and custom properties just work. Legacy switches with no modern meaning warn and carry on instead of blowing up, so nothing old breaks; a --strict flag makes them fatal when you want CI to be picky. It ships with a headless Chromium bundled in, so it still works offline from one install — no browser to provision, no service to babysit. You install it with one line, and it backs up whatever was there so you can undo it in five seconds:

curl -fsSL https://raw.githubusercontent.com/rvanbaalen/bilihtmltopdf/main/setup.sh | bash

It’s a drop-in: same command, same path, same flags, so nothing that calls it has to change. It renders with a current Chromium, so the CSS the old engine couldn’t handle just works. That’s the whole idea.

I built it by prompting Fable 5, Anthropic’s model — it did the research, the Go, and the release plumbing, and I steered what it should do and caught it when it was wrong. A weekend side project that turned out to be genuinely usable.

The binary on my server is called wkhtmltopdf again, and the code that calls it never noticed. Same tool, same command, newer engine.