Your PDF button, or the invoice email, fails in ERPNext on Docker with a wkhtmltopdf ConnectionRefusedError. The traceback ends Exit with code 1 due to network error: ConnectionRefusedError. This is not a missing xvfb. wkhtmltopdf, running inside your backend or worker container, tried to open a URL that Frappe wrote into the page, and nothing was listening there. On the stock pwd.yml stack that URL is http://localhost/… on port 80, because the nginx in front of Frappe drops the :8080. This post shows how Frappe picks the URL, a checker that reads your error text and your setup, and the smallest fix: the port in host_name.
I lead ERPNext and Frappe implementations at MithTech, a team of Frappe specialists in Bengaluru. We ran every case in this post on 14 October 2026 against the published images.
Short answer
A wkhtmltopdf ConnectionRefusedError means the container that runs it could not open a URL Frappe wrote into the page. xvfb has nothing to do with it. In a frappe_docker stack, set host_name to the nginx service and its port, http://frontend:8080 in pwd.yml, then restart backend and frontend. On v16 you can use Chrome instead.
What does the wkhtmltopdf ConnectionRefusedError mean?
It means wkhtmltopdf could not open a URL that Frappe wrote into the page, because nothing was listening there. Frappe renders the print view as HTML, turns every relative link into an absolute one and hands the page to wkhtmltopdf. That program runs in the same container as Frappe and fetches the page's stylesheet itself. When the fetch fails, Frappe raises a Python OSError, which you find in the Error Log:
OSError: wkhtmltopdf reported an error:
Exit with code 1 due to network error: ConnectionRefusedError
The last word names the network failure and narrows the cause. These are the four texts we produced, and the checker below starts from the same four.
| Error text | What it says | Likely cause |
|---|---|---|
ConnectionRefusedError | The name resolved. Nothing listens on that port. | The URL points at a port with no server. This post. |
HostNotFoundError | The name does not resolve in the container. | host_name holds a public or mistyped name. |
PDF generation failed because of broken image links | wkhtmltopdf reached something, but a file did not load. | The URL reaches gunicorn directly, such as backend:8000, or a file is gone. |
TimeoutError: Chromium took too long to start | This is the Chrome generator. | A pwd.yml stack on v16 without chromium_path. See the last section. |
Is a missing xvfb the cause?
No. The same image builds the same PDF with no X server, and it fails only when the stylesheet URL has nothing listening. The idea comes from frappe/erpnext #47539, filed in May 2025. The reporter ran xvfb-run in a v15.61.0 container, found it missing and concluded that wkhtmltopdf needs it. A collaborator replied that the steps worked on a newer version, and the issue was closed on 15 May 2025 with no fix linked.
We checked on 14 October 2026, in containers with no network and no display, on Docker Desktop with Linux containers.
| Image | wkhtmltopdf | X server | What happened |
|---|---|---|---|
frappe/erpnext:v16.50.0 | 0.12.6.1, patched Qt | none installed, DISPLAY empty | A page with no external files: a PDF of 8,396 bytes. Stylesheet on a closed port: ConnectionRefusedError. A server answering on that port: the same 8,396 bytes. |
frappe/erpnext:v15, Frappe 15.108.0 | 0.12.6.1, patched Qt | none installed, DISPLAY empty | The same three results. |
One thing changed between the failing run and the passing run: whether anything answered at the URL. The X server never existed. wkhtmltopdf's README says the tools run headless, and the frappe_docker images install the 0.12.6.1 build with patched Qt. A code search for xvfb in frappe_docker, frappe and erpnext returned no hits on 14 October 2026. We did not run the issue's own image, v15.61.0; the Containerfile frappe_docker used then installs the same build.
How does Frappe choose the URL wkhtmltopdf opens?
Frappe takes the host from host_name if it is set, then from the request's Host header, then from the site name. Before it renders, get_pdf calls scrub_urls, which puts get_url() in front of every relative link (pdf.py, data.py). get_url() chooses in this order (source):
host_name, orhostname, from the site or common config.- Inside a web request, the request's
Hostheader, withhttps://whenX-Forwarded-Protosays so. - In a background job, which has no request,
http://plus the site name.
It adds http:// when there is no scheme, and a port only if http_port or webserver_port is set (on v16, webserver_port counts only in developer mode). The frappe_docker images set neither: sites/common_site_config.json is empty in both images we ran.
In frappe_docker the port you typed never reaches Frappe. The nginx in the frontend service sends proxy_set_header Host $host; (nginx-template.conf), and $host has no port. A browser on http://localhost:8080 reaches Frappe as Host: localhost, so step 2 gives http://localhost: port 80. We captured the page handed to wkhtmltopdf, and its stylesheet link was:
href="http://localhost/assets/frappe/dist/css/print.bundle.6F67YCQA.css"
Inside the backend container nothing listens on port 80: nginx is another container on 8080, and gunicorn is on 8000.
A background job has no browser, so it uses the site name. In pwd.yml the site is called frontend, so a worker with no host_name opens http://frontend: port 80 again, same failure. Elsewhere the two paths can differ. In frappe_docker #1769 the reporter's PDF worked from the desk and failed from the mail queue.
One file decides whether a failed fetch is fatal. wkhtmltopdf ignores a refused fetch when the file name, after its first dot, is css, js, png, jpg, jpeg or gif (source). The print stylesheet is print.bundle.<hash>.css, so it is never ignored. A refused logo.png was a warning for us; a refused logo.v2.png or logo.svg stopped the PDF. Frappe turns four wkhtmltopdf errors into the "broken image links" message, and ConnectionRefusedError is not one of them, so you get the raw error.
Which URL does your container open?
Pick the error text you see first. Then say who builds the PDF, what host_name holds and what sits in front of Frappe. The checker works out the URL, reads it back against your error and shows the fix. It opens on a labelled sample.
Choose the error text you see, then say who builds the PDF, what host_name holds, the address in your browser and what sits in front of Frappe. The checker shows the URL wkhtmltopdf opens from inside your container, what that URL gives, and the fix. It says it cannot tell where it has no measurement.
Your error and your setup
These are sample answers: the stock pwd.yml stack, PDF button pressed at localhost:8080. Change them to yours.
Copy the last line of the traceback from the Error Log, or from docker compose logs.
A button press and a queued job reach Frappe differently.
Leave blank if you have not set it.
As typed in the address bar when you press the PDF button.
The folder under sites/. The stock pwd.yml names it frontend.
The measured answers cover the pwd.yml stack.
http://localhostAgrees with your error textMeasured- No host_name, so Frappe uses the Host header. The frappe_docker nginx sends the host name without its port, so the port you typed is lost.
- ConnectionRefusedError: the name in the URL resolved, and nothing was listening on that port inside the container.
- That matches the error text you chose.
Check from inside the container
docker compose exec backend curl -sS -o /dev/null -w "%{http_code}\n" http://localhost/api/method/ping- 200: the site answered, and wkhtmltopdf can fetch from this URL.
- curl: (7) Failed to connect: wkhtmltopdf says ConnectionRefusedError.
- curl: (6) Could not resolve host: wkhtmltopdf says HostNotFoundError.
- Any other code: a server answered, but not as your site. In our runs gunicorn addresses ended in broken image links.
Fix
Point host_name at an address the container can open. On the frappe_docker stacks that is the nginx service and its port (frontend and 8080 in pwd.yml; use your own if you changed them).
docker compose exec backend bench --site frontend set-config host_name http://frontend:8080
docker compose restart backend
docker compose restart frontendhost_name also feeds the links Frappe writes into emails and notifications, so they will start with the internal address. The section on what host_name changes lists the ways to keep public links.
Measured on the frappe_docker pwd.yml stack, frappe/erpnext v16.50.0 and Frappe 15.108.0, on 14 October 2026. Other stacks, or a site config with http_port or webserver_port set: use the check line.
The checker models get_url() and the frappe_docker nginx. It does not look at your server. Where it has no measurement it says it cannot tell and gives the curl line that finds out. These are the measured cases, all on the stock pwd.yml stack, for reading without it. A name under .invalid never resolves (RFC 6761), so two rows use one.
11 rows · click a column to sort
Button at localhost:8080 | not set | http://localhost | ConnectionRefusedError |
Button at frontend:8080 | not set | http://frontend | ConnectionRefusedError |
Button at localhost:8080 | http://frontend:8080 | http://frontend:8080 | PDF builds |
Button at localhost:8080 | frontend:8080 | http://frontend:8080 | PDF builds |
Button at localhost:8080 | http://localhost:8080 | http://localhost:8080 | ConnectionRefusedError |
Button at localhost:8080 | https://erp.example.invalid | https://erp.example.invalid | HostNotFoundError |
Button at localhost:8080 | http://backend:8000 | http://backend:8000 | Broken image links |
Button at localhost:8080 | http://127.0.0.1:8000 | http://127.0.0.1:8000 | Broken image links |
| Background job | not set | http://frontend | ConnectionRefusedError |
| Background job | http://frontend:8080 | http://frontend:8080 | PDF builds |
| Background job | https://erp.example.invalid | https://erp.example.invalid | HostNotFoundError |
To check any URL yourself, run curl from the container that builds the PDF: backend for the button, queue-short for a background job. Add -f pwd.yml after compose if that is your file.
docker compose exec backend curl -sS -o /dev/null -w "%{http_code}\n" http://frontend:8080/api/method/ping
A 200 means the site answered. curl: (7) Failed to connect is ConnectionRefusedError, and curl: (6) Could not resolve host is HostNotFoundError. Any other code means a server answered, but not as your site.
How do you fix it for your setup?
Write the nginx service name and its port into host_name, then restart backend and frontend. In a stock pwd.yml that is four steps. We ran each case by writing the key into site_config.json; bench set-config writes the same file.
Test the address from inside the container
Run the curl line with http://frontend:8080. A 200 means the container reaches nginx there. frontend and 8080 are the service name and port in pwd.yml and compose.yaml; use yours if you changed them.
Set host_name
docker compose exec backend bench --site frontend set-config host_name http://frontend:8080. Replace frontend after --site with your site folder name.
Restart backend, then frontend
docker compose restart backend, then docker compose restart frontend. Gunicorn keeps the site config it read until it restarts (source), and nginx looks up backend only when it starts, so restarting backend alone can leave nginx answering 502. Queue workers read the config for each job (source) and need no restart.
Print again, from the button and from a queued job
Press the PDF button, then send an email with a PDF attached. Both paths now fetch from http://frontend:8080.
The right value depends on what you run.
| Your setup | What to do | How far it is checked |
|---|---|---|
pwd.yml, one host | host_name = http://frontend:8080 | Run by us: v16.50.0 (button, worker) and Frappe 15.108.0 (button) |
compose.yaml, one site | The same host_name, and set FRAPPE_SITE_NAME_HEADER on frontend to your site name. By default nginx asks Frappe for a site named frontend. | Read from compose.yaml and the nginx template. Not run by us. |
| Behind Traefik, Cloudflare or another TLS proxy | The same internal address. Emails then carry it. | Follows from the topology. Not run with a proxy. |
A public host_name you want to keep | The public name must resolve inside the container to something serving the right port. | Reported in frappe_docker #1547. Not run by us. |
| Several sites in one bench | Check which site a fetch for http://frontend:8080 reaches. | Not run. |
| v16 with Chrome | Skip host_name. Set chromium_path. | Run by us. See the last section. |
A maintainer notes in that thread that pwd.yml is a demo stack and not for production.
What else does host_name change?
It changes every URL Frappe builds, not only the one in the PDF, so emails start to link to the internal address. On v16.50.0 we asked Frappe for the link to a Note:
host_name | Link Frappe builds |
|---|---|
| not set | http://frontend/desk/note/<name> |
http://frontend:8080 | http://frontend:8080/desk/note/<name> |
https://erp.example.com | https://erp.example.com/desk/note/<name> |
Reporters describe the email links in frappe_docker #1680 and #1547. One reporter in frappe/erpnext #36018 found that http://127.0.0.1:80 fixed the PDF and broke OAuth and email links. All three were closed without a general fix. The ways to keep both come from the community, and each is labelled:
- A public
host_nameand an alias inside Docker. An extra nginx container carries the public name as a network alias and forwards tofrontend:8080. One reporter described it in January 2026 and another said it worked; a later comment added a setting that turns IPv6 off onbackend. It is contested: a Frappe maintainer said in January 2025 that a publichost_nameworks after PR #1552, and the thread's author replied that it still failed. Not run by us. - An nginx
map $hostthat swaps the internal name for the public one. A frappe_docker maintainer gave it for VPN and IP-address setups. Not run by us. extra_hostsforlocalhostonbackend, suggested in frappe/erpnext #45653. Not run by us.
What is the way out of wkhtmltopdf?
On v16, switch the PDF generator to Chrome. It answers the site's own files from disk, so the URL does not have to be reachable. Frappe's Chrome generator arrived with PR #34191, merged on 14 October 2025, and shipped in v16.0.0 on 12 January 2026. While Chrome loads the page, Frappe answers each request whose URL starts with the request's own host URL from the bench, so nothing crosses the network (page.py).
We ran it on v16.50.0 with the browser at localhost:8080 and no host_name. The request that fails with wkhtmltopdf returned a PDF, twice out of two. The Chrome 504 post shows where the PDF Generator setting lives. Before you switch:
- Set
chromium_pathfirst.compose.yamlsets it (source);pwd.ymldoes not. Without it, Frappe downloaded a 243 MB Chromium into the container on our first Chrome request, and the request ended withTimeoutError: Chromium took too long to start. Rundocker compose exec backend bench set-config -g chromium_path /usr/lib/chromium/chromium-headless-shell, then restartbackend. frappe_docker changed the path in v4.0.1 (PR #2008), so use the one yourcompose.yamlsets. - A different
host_nameundoes the shortcut. We read this in the source and did not run it: ifhost_namenames another host, the URL no longer starts with the request's host, and Chrome goes to the network. - One button still uses wkhtmltopdf. Full Page, Get PDF ignored the Chrome setting on v16 (frappe #43672, open).
- Chrome has failures of its own. The Chrome 504 post, linked above, covers the hang and the Print Designer path bug.
On v15 Frappe has no Chrome generator. A reporter in frappe_docker #1620 found that installing Print Designer brought a Chromium that rendered correctly; we did not test it. v15 against v16 lists what else changes.
Frappe merged the removal of wkhtmltopdf into develop on 10 October 2026 (PR #44253), and the install docs say PDFs use Chromium from version 17. There was no v17 tag on 14 October 2026, and frappe_docker #2017 asks the images to drop wkhtmltopdf by default when it ships.
Frequently asked questions
+Why does ERPNext Docker PDF fail with wkhtmltopdf ConnectionRefusedError?
wkhtmltopdf, inside your backend or worker container, could not open the URL Frappe wrote into the page. On the stock pwd.yml stack that URL is http://localhost/… on port 80, since nginx drops the port you typed. Set host_name to http://frontend:8080 and restart backend and frontend.
+Do I need xvfb for wkhtmltopdf in Frappe Docker?
No. The Frappe images carry wkhtmltopdf 0.12.6.1 with patched Qt, which runs without a display. On 14 October 2026 it built a PDF in frappe/erpnext:v16.50.0 and in Frappe 15.108.0 with no X server. Installing xvfb would not touch the failing step, a network connection.
+What should host_name be in frappe_docker's pwd.yml?
http://frontend:8080: the nginx service name and its port, both in pwd.yml. Set it with bench --site frontend set-config host_name http://frontend:8080, then restart backend and frontend. It also becomes the address in email links, so check those.
+Why does the PDF button work but the emailed PDF fail?
The button runs inside a web request, so Frappe builds URLs from the Host header. A queued job has no request, so it uses the site name. The two can open different addresses, and only one may be reachable. Setting host_name makes both use one address.
+Why do my emails link to frontend:8080 after I set host_name?
host_name feeds every URL Frappe builds, so notification links use it too. On v16.50.0 a Note link changed from http://frontend/desk/note/… to http://frontend:8080/desk/note/…. To keep public links, use a public host_name that resolves inside Docker, or use Chrome on v16.
+Does the Chrome PDF generator in v16 avoid this error?
In our run, yes. On v16.50.0 with the browser at localhost:8080 and no host_name, the request that fails with wkhtmltopdf returned a PDF twice out of two, once chromium_path was set. Chrome answers files under the request's own host from disk. Full Page, Get PDF still used wkhtmltopdf on 14 October 2026.
+Which versions does this apply to, and what happens if I leave it?
Frappe v15 and v16 on Docker, wherever wkhtmltopdf makes the PDF. We ran v16.50.0 and Frappe 15.108.0. Left alone, each PDF that loads the print stylesheet fails: print, download and PDFs attached to email. Frappe v17 drops wkhtmltopdf, but no v17 tag existed on 14 October 2026.
What is the bottom line?
A wkhtmltopdf ConnectionRefusedError names a network failure, so treat it as one. Find the URL your container opens, make it reachable by putting the nginx address in host_name, or move to Chrome on v16. If you would rather not run the stack yourself, our managed hosting and support cover it. The same stack can also show a scheduler Process Not Found alarm, which is false.
Manoj
Comments & ratings
No comments yet. Start a new discussion.
Worked with us? Leave a Google review