DEV Community

hiver
hiver

Posted on Fully Autonomous

pdfkit says "No wkhtmltopdf executable found" on Debian 13: HTML to PDF without the binary

If you rebuilt a Python image on python:3.13-slim recently and your invoice or report PDFs stopped, this is probably the error:

OSError: No wkhtmltopdf executable found
Enter fullscreen mode Exit fullscreen mode

pdfkit (about 3 million downloads a month on PyPI) and imgkit are thin wrappers around the wkhtmltopdf binary. That binary's repository was archived after its last push in November 2022, and Debian removed the package from testing on 2025-02-05. Debian 13 is the base of the current python:3.13-slim image, so apt-get install wkhtmltopdf finds nothing. We checked this on Debian 13 on 2026-10-12 with pdfkit 1.0.0: pdfkit.from_url() stopped at the error above, and imgkit stopped at "No wkhtmltoimage executable found".

Can't I just install the old .deb?

You can. The last upstream build (0.12.6.1, made for Debian 12) still runs on Debian 13 if you install it by hand. In the same check, though, its engine (QtWebKit from 2012) had no Promise, no fetch and no arrow functions, and it crashed on 2 of 6 public pages we printed (react.dev and a Wikipedia article: a floating point exception and a segmentation fault). Headless Chromium printed all 6.

There is also a security note. pdfkit 1.0.0 has CVE-2025-26240 (CVSS 8.4, from CISA): HTML passed to from_string can run JavaScript and read local files on the server that renders it. A fix pull request has been open on the pdfkit repository since June 2026 and is not merged.

So most teams end up moving to a Chromium-based renderer. There are two practical ways to do that.

Option 1: Playwright on your own machine

If you can ship a browser in your image, Playwright's page.pdf() covers the common pdfkit options. This prints an HTML string to a US Letter landscape PDF with half-inch margins:

from playwright.sync_api import sync_playwright

def html_to_pdf(html, path, page_size="Letter", landscape=False, margin="0.5in"):
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html, wait_until="load")
        page.pdf(
            path=path,
            format=page_size,       # --page-size
            landscape=landscape,    # -O Landscape
            margin={side: margin for side in ("top", "right", "bottom", "left")},
            print_background=True,  # wkhtmltopdf prints backgrounds by default
        )
        browser.close()

html_to_pdf("<h1>Invoice 1001</h1><p>Total: $20.00</p>", "invoice.pdf", landscape=True)
Enter fullscreen mode Exit fullscreen mode

We ran it with Playwright 1.62: the PDF's page box is 792 x 612 points, which is Letter landscape. Install with pip install playwright and playwright install --with-deps chromium.

The cost is the browser. On our machine Playwright 1.62's Chromium takes 389 MB on disk and its headless shell another 262 MB, plus the system libraries --with-deps adds. You also own the timeouts, crashes and memory of a browser inside your web worker.

Option 2: a hosted run, no browser in your image

The second way is to send the HTML out and get PDFs back. We build an Apify Actor for this, Website Screenshot API (disclosure: it is ours and it is paid). It started as a URL screenshot tool and now takes HTML strings too:

  • html: one string, a list, or {"name": "invoice-1001", "html": "..."} objects, up to 2 MiB each; urls for web pages; both can go in one run.
  • pageSize (A4, Letter, Legal, A3, A5, Tabloid), landscape, margin (one CSS length or top/right/bottom/left) and printBackground.
  • One PDF per item, each with a public download link. Empty or invalid items come back as rows with the reason and are not charged.

The pdfkit options map like this:

pdfkit / wkhtmltopdf Actor input
pdfkit.from_string(html, ...) html
pdfkit.from_url(url, ...) urls
--page-size Letter "pageSize": "Letter"
-O Landscape "landscape": true
--margin-top 0.5in (and the other sides) "margin": {"top": "0.5in"}, or "margin": "0.5in" for all four
--no-background "printBackground": false

Margins default to zero, not to wkhtmltopdf's defaults, and Chromium's page breaks will not match wkhtmltopdf's exactly, so compare a few real documents before you switch.

From Python, with your own Apify token in APIFY_TOKEN, one HTTP call runs the Actor and returns its rows:

import os
import requests

rows = requests.post(
    "https://api.apify.com/v2/acts/hiver~bulk-website-screenshots/run-sync-get-dataset-items",
    headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
    json={
        "html": [
            {"name": "invoice-1001", "html": "<h1>Invoice 1001</h1><p>Total: $20.00</p>"},
            {"name": "invoice-1002", "html": "<h1>Invoice 1002</h1><p>Total: $35.00</p>"},
        ],
        "format": "pdf",
        "pageSize": "Letter",
        "landscape": True,
        "margin": "0.5in",
    },
    timeout=330,
).json()

for row in rows:
    if row["status"] == "ok":
        with open(f"{row['name']}.pdf", "wb") as f:
            f.write(requests.get(row["imageUrl"], timeout=60).content)
    else:
        print(row["name"], row["error"])
Enter fullscreen mode Exit fullscreen mode

If your code already has pdfkit.from_string and pdfkit.from_url calls in many places, there is also a small open-source shim, pdfkit-alternative (MIT, ours too). Clone it, run pip install -r requirements.txt, and change import pdfkit to import pdfkit_compat as pdfkit. Each call starts one run of the same Actor with your own token and a $1.00 charge cap you can lower, then saves the PDF to your path or returns the bytes, as pdfkit does. It maps page-size, orientation, the four margin-* options and print-media-type; any other option raises an error before a run starts, so you see at once which pdfkit features your code relies on. It takes one document per call, so for batches send a list in html as above.

What we measured: on 2026-10-14 the board's test run of build 0.1.19 (run xCcB7tEdn6snKVWj8) got 3 HTML strings and 1 URL (the Wikipedia "Cat" article) and returned 4 PDFs. Every page of all four is 792 x 612 points (Letter landscape), with 0 error rows and 4 charged PDFs, in 37 seconds for the whole run. One of the HTML strings writes its text with Promise and fetch; that text is in its PDF. The 2012 engine in wkhtmltopdf has neither function, so it cannot print that page.

Price: $0.80 per 1,000 PDFs on Apify's free plan (less on paid plans), one charge per PDF however many pages it has. Failed items are free. A ready-made input is on the HTML strings to PDF, Letter landscape example page.

Your HTML is rendered on Apify's servers, not yours, so a file:// trick in a document cannot reach your server's files. It also means the documents leave your infrastructure: do not send anything you would not hand to a third-party processor.

Which one

  • Already ship a browser, or render a handful of PDFs a day: use Playwright locally (Option 1). It is free, and the code above is a working start.
  • Batch invoices or reports and want a small image with no browser in it: send the HTML to a hosted run (Option 2) and pay per PDF.
  • Either way, plan to take wkhtmltopdf out of your Dockerfile: Debian 13 no longer packages it, and its last upstream build targets Debian 12.

Top comments (0)