Stirling-PDF is not an unstable application. Almost every crash, hang and quietly mangled output traces back to one of fifteen decisions you made outside the app: an unbounded container, the wrong image variant, a reverse proxy that gives up after 60 seconds, or an OCR call with no language pack behind it. The Java process inside the container claims a default heap of 25 percent of visible RAM, office conversions serialise through a single background LibreOffice process, and every job writes scratch files to disk before it returns a single byte. Correct those four assumptions and most of the remaining catalogue stops happening.
TL;DR by profile:
- The weekend homelab tinkerer (one N100 mini PC, roughly ten PDFs a week, merge and split only): run the ultra-lite image with a hard 1 GB container limit, because you never touch the two subsystems that consume memory.
- The scan hoarder (a decade of paper, 40,000 pages, OCR every one of them): run the full image, mount a real tessdata volume with your languages, and process in batches of 50 pages or fewer, because OCR cost scales with page count and DPI, not file count.
- The automation builder (an n8n or cron job calling the Stirling-PDF API unattended): add your own queue and set explicit timeouts on both sides, because the app accepts every concurrent request you throw at it and holds each one in memory.
- The small team sharing one instance (four people behind a VPN, mixed office conversions): budget for LibreOffice serialisation and raise proxy timeouts before you raise CPU, because your bottleneck is a single conversion process, not cores.
- The NAS owner (a box already running twenty other containers): set the memory limit and the temp volume first, because an unbounded Stirling-PDF job is the container most likely to evict your other services.
The central tradeoff is headroom against completion: every limit that stops Stirling-PDF from taking down its host also stops some legitimate large job from ever finishing.
Table of contents
- What is actually failing when Stirling-PDF dies, and how do you confirm it in two minutes?
- Anti-pattern 1 and 2: no container memory limit, and no JVM heap ceiling inside it
- Anti-pattern 3 and 4: running the wrong image variant, then calling a tool it does not contain
- Where does Stirling-PDF write its temporary files, and why does that fill your disk?
- Why does OCR hang, fail outright, or return a file with no selectable text?
- Anti-pattern 9 and 10: treating office conversion as fast, parallel and font-independent
- Why does a large upload fail before Stirling-PDF ever sees the file?
- Anti-pattern 12: reverse proxy timeouts shorter than the job you just started
- Anti-pattern 13: compression and repair settings that return a valid file with wrong content
- Anti-pattern 14: volume permissions, custom fonts and language packs mounted incorrectly
What is actually failing when Stirling-PDF dies, and how do you confirm it in two minutes?
Three different failures get reported as “Stirling-PDF crashed”, and they need opposite fixes. Run docker inspect stirling-pdf --format '{{.State.ExitCode}}' and docker logs --tail 200 stirling-pdf before you change anything. The exit code alone separates two of the three cases.
-
Exit code 137, container killed by the host: the kernel OOM killer reclaimed the process because the container exceeded its cgroup memory limit, or the host ran out of RAM entirely. The application log ends mid-sentence with no stack trace, which is the giveaway. Confirm it with
dmesg -T | grep -i oomon the host ordocker inspectreportingOOMKilled: true. -
A
java.lang.OutOfMemoryError: Java heap spacestack trace, container still running: the JVM hit its own heap ceiling while the container still had free memory. The web UI usually stays reachable and only that one job fails. This is the fix nobody applies, because the container looks healthy. - No error at all, the request just never returns: the job is running, but something between your browser and the app gave up first. A 504 after roughly 60 seconds points at the reverse proxy, not at Stirling-PDF.
- HTTP 200 with a file that opens but is wrong: text vanished, fonts substituted, images blurred to unreadable. Nothing in the log marks this as an error, because from the application’s point of view it succeeded.
The first two are memory problems, the third is a timeout problem, and the fourth is a settings problem. Diagnosing the wrong one costs you a weekend.
Anti-pattern 1 and 2: no container memory limit, and no JVM heap ceiling inside it
Docker applies no memory limit by default. A container-aware JVM with no -Xmx claims a maximum heap of 25 percent of the memory it can see, which is the host’s total RAM when you set no limit. Both defaults are wrong for this workload, and they fail in opposite directions.
mem_limit, no heap setting
25 percent of host RAM as max heap, on 8 GB that is 2 GB
The host runs out of RAM before the container does, and your other services get evicted first
mem_limit: 2g, no heap setting
512 MB max heap, calculated from the limit
OutOfMemoryError on a job the machine could easily have handled
mem_limit: 2g, heap raised to 1.5 GB
1.5 GB heap, 512 MB left for everything else
Exit 137 the moment a native helper process starts, because Ghostscript and Tesseract live outside the heap
mem_limit: 4g, heap capped at 2 GB
2 GB heap, 2 GB for native processes and page cache
Nothing, for a single-user instance
The subtlety that catches people out: capping the Java heap does not cap the container. LibreOffice, Ghostscript, Tesseract and qpdf are separate native processes. Their memory counts against the cgroup limit but never against -Xmx.
Set both, and leave the native processes at least as much room as the heap. Add JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=50 rather than a fixed -Xmx, so the ratio survives a change to the container limit. Then watch docker stats stirling-pdf during your largest real job instead of guessing.
Anti-pattern 3 and 4: running the wrong image variant, then calling a tool it does not contain
Stirling-PDF ships as more than one image, and they are not interchangeable. Pulling latest because it sounds current, or ultra-lite because it sounds efficient, decides which of the 50 plus tools actually work at runtime. The UI still shows every button either way, which is why this fails so confusingly.
-
stirlingtools/stirling-pdf:latest-ultra-lite: core PDF manipulation only, so merge, split, rotate, reorder and metadata edits. No OCR, no LibreOffice, no Python. It is the right choice if you genuinely never convert or OCR, and the wrong choice the first time you try. -
stirlingtools/stirling-pdf:latest: the standard build, with OCR and office conversion included. This is the default answer for most self-hosters, and the variant the rest of this article assumes. -
stirlingtools/stirling-pdf:latest-fat: everything preinstalled, including the ebook and advanced HTML tooling that the standard image otherwise fetches at container start when you setINSTALL_BOOK_AND_ADVANCED_HTML_OPS=true. It trades disk for a container that starts ready and needs no outbound network on boot. -
The tool you call but did not install: the request reaches the backend, the underlying binary is absent, and you get a generic failure rather than “this image cannot do that”. Confirm with
docker exec stirling-pdf which soffice tesseractbefore blaming your configuration.
Where you run it changes how much this bites. On a self-managed VPS, a home server or a NAS you pick the tag yourself in a compose file. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user, so the variant arrives already chosen by the packaged app. Either way, verify which binaries exist before you design a workflow around them.
Where does Stirling-PDF write its temporary files, and why does that fill your disk?
Every non-trivial operation is a file-on-disk pipeline, not an in-memory transform. The container writes scratch files under /tmp/stirling-pdf, hands them to Ghostscript, Tesseract or LibreOffice, and only then streams a result back to your browser. If you never mounted anything at that path, all of it lands on the container’s writable overlay layer, on the same filesystem as your Docker root.
- The multiplication nobody budgets for: an OCR pass rasterises every page, writes the image set, writes an intermediate PDF, then writes the output. Peak disk use is several times the input size, and it happens before you see any progress at all.
-
Failed jobs do not always clean up: a container killed at exit 137, or a request abandoned when you closed the tab, leaves its scratch files behind. Check with
docker exec stirling-pdf du -sh /tmp/stirling-pdfafter a week of real use. -
The overlay layer is the worst possible target: it is slow, it counts against your Docker storage pool, and it disappears on
docker compose down, taking any recoverable partial output with it. - Two fixes, different tradeoffs: mount a tmpfs sized at 1 GB or 2 GB for speed, accepting that a job larger than the tmpfs fails outright, or bind mount a real directory for capacity, accepting slower rasterisation on spinning disks.
-
Cleanup is configurable: the temp file management block in
/configs/settings.ymlcontrols the cleanup interval, the maximum age of scratch files and whether a sweep runs at startup. Set it once rather than adding a cron job.
This matters more on constrained storage than on a roomy VPS, whether that is a NAS volume, a home server SSD or a Yundera instance.
Why does OCR hang, fail outright, or return a file with no selectable text?
OCR is the most expensive thing this application does, and it is driven by option combinations most people pick at random. Three failures dominate: no language data present, the wrong OCR mode for the input, and a page count the container cannot chew through before something upstream gives up.
Language data comes first. Run docker exec stirling-pdf tesseract --list-langs. If your language is missing, OCR fails or produces nonsense, and mounting a volume at /usr/share/tessdata with the traineddata files you need is the fix, not a setting change.
Cost scales with pages and resolution, not with file count. A 400 page scan at 600 DPI is a fundamentally different job from 40 pages at 200 DPI, even at similar file sizes. Batch large documents into chunks of 50 pages or fewer, run them one at a time, and treat any OCR job over a few minutes as something that needs a raised timeout rather than a retry.
Anti-pattern 9 and 10: treating office conversion as fast, parallel and font-independent
Converting DOCX, XLSX or PPTX to PDF does not happen in Java. Stirling-PDF hands the file to a headless LibreOffice process inside the container. That single detail explains both of this section’s anti-patterns.
-
The first conversion after a container start is the slow one: the headless
sofficeprocess has to initialise before it can do any work. Measure your second conversion, not your first, or you will size the machine against a number you will never see again. - Conversions serialise, so more cores do not help: requests queue behind one conversion backend. Two users submitting large presentations at the same time do not each get half the speed, the second one waits. If office conversion is your bottleneck, raise your reverse proxy timeout before you add vCPUs.
-
A wedged
sofficeprocess blocks everything behind it: one malformed document can leave the backend stuck, and every later conversion times out while the rest of the application stays perfectly healthy. Check withdocker exec stirling-pdf ps aux | grep soffice, and restart the container rather than debugging the document. - Missing fonts corrupt layout silently: the container ships a limited font set. A document written in Calibri or Cambria gets substituted glyphs, so line breaks move, tables overflow and page counts change. The output is a valid PDF that does not match the original, and nothing in the log calls this an error.
-
Fix fonts by mounting them, not by hoping: bind mount your TTF files into a directory under
/usr/share/fonts, then confirm withdocker exec stirling-pdf fc-list | wc -l. Carlito and Caladea are the metric-compatible stand-ins for Calibri and Cambria, and Liberation Sans covers Arial.
Why does a large upload fail before Stirling-PDF ever sees the file?
A 413 error, or a progress bar that reaches 100 percent and then dies, is almost never the application. Every layer between your browser and the container can refuse a body, and each one has a different default. The container logs stay empty, which is the clue: if Stirling-PDF had rejected the file, it would have said so.
Layer Default body limit What you see nginx or nginx proxy managerclient_max_body_size defaults to 1 MB
HTTP 413 within seconds, no entry in the Stirling-PDF log at all
Caddy or Traefik
No request body limit by default
Nothing, these two are rarely the culprit unless you added a buffering middleware yourself
Apache httpd
LimitRequestBody defaults to unlimited
Nothing, unless a distribution config or a hardening guide set it for you
Cloudflare proxied hostname
100 MB per request on the free plan
HTTP 413 from Cloudflare’s edge, with a Cloudflare branded error page rather than your own
Stirling-PDF itself
A multipart upload limit in /configs/settings.yml
A clean application-level rejection that does appear in the log
Diagnose it by bypassing the chain. Send the same file directly to the container port with curl -F "[email protected]" http://localhost:8080/... from the host. If that works and the browser does not, the problem is in front of the app, and no amount of container tuning will fix it.
Raise the limits in order, from the outermost layer inward, and raise them to the same value. A proxy that accepts 500 MB in front of an app that accepts 50 MB just moves the failure one hop later, after the user has already spent the upload time.
Anti-pattern 12: reverse proxy timeouts shorter than the job you just started
A 504 after roughly a minute, on a job you know takes longer, is a proxy default and nothing else. The container never stopped working. It is still rasterising pages while your browser shows an error page.
-
nginx gives up after 60 seconds:
proxy_read_timeoutandproxy_send_timeoutboth default to 60s, which is shorter than almost any real OCR or large office conversion. Raise them to 300s or 600s in the location block for Stirling-PDF, not globally. - Cloudflare cuts the origin off at 100 seconds: a proxied hostname on the free plan returns error 524 when the origin has not responded in time, and you cannot raise that on the free plan. Long jobs behind an orange cloud need a different path to the app, such as a VPN or a direct hostname.
- Caddy and Traefik do not impose a short default here: if you use either and still see a timeout, look at the client, at Cloudflare, or at an explicit middleware you configured yourself.
- The retry is what actually kills the container: the abandoned job keeps running and keeps its memory. Clicking the button again starts a second copy of the same work, so peak memory doubles, and the container that was surviving one job gets killed at exit 137 running two. This is why “it broke worse when I tried again” is such a common report.
- A timeout is not a substitute for batching: raising the ceiling to 600s makes a long job possible, but a job that needs 600 seconds should be split. Confirm the real duration by timing it against the container port directly, with the proxy out of the picture, before you decide which number to set.
Anti-pattern 13: compression and repair settings that return a valid file with wrong content
This is the failure class with no error message. The request returns 200, the PDF opens, and the damage only surfaces weeks later when someone needs the original. Compression, flattening, repair and sanitisation all rewrite the document structure, and several of them discard data by design.
- Aggressive compression downsamples images, permanently: the optimisation levels run from light restructuring to heavy image resampling. On a scanned invoice, a high level can drop resolution below the point where the text is legible, and there is no undo. Test one representative document at each level before you apply anything to a batch.
- Targeting an output size lets the tool decide how much to destroy: asking for a specific final size hands the algorithm permission to resample as far as it needs to. Set a level you have tested instead of a size you hope for.
- Flattening converts form fields into static content: an interactive form becomes a picture of a form. Entered data is preserved visually, but the fields are gone, and any workflow that reads field values downstream stops working.
- Repair rewrites the file structure and can drop what it does not understand: bookmarks, annotations, attachments and tagging are the usual casualties. Repair is for files that genuinely fail to open, not a routine step.
- Any rewrite invalidates a digital signature: compress, repair, flatten, sanitise or even a metadata edit breaks the cryptographic seal. If a document is signed, it leaves the pipeline untouched.
-
Verify instead of trusting the 200: compare page counts and extracted text between input and output with
pdfinfoandpdftotextfrom poppler-utils, and runqpdf --check output.pdf. Keep the original file until that comparison passes.
Anti-pattern 14: volume permissions, custom fonts and language packs mounted incorrectly
Two mount mistakes account for most of the “I followed the guide and it still does not work” reports. Both look correct in the compose file.
-
Mounting an empty host directory over a populated container path hides what was already there: bind mount an empty folder at
/usr/share/tessdataand the traineddata files shipped in the image vanish, including English. OCR that worked before your fix stops working after it. Copy the existing contents out first withdocker cp stirling-pdf:/usr/share/tessdata ./tessdata, then mount that directory back. -
The same trap applies to fonts: mounting your font collection at
/usr/share/fontsreplaces the container’s entire font tree. Mount a subdirectory such as/usr/share/fonts/truetype/customso the bundled families survive alongside yours. -
Docker creates missing bind mount sources as root: if the host path does not exist when the container starts, Docker makes it owned by
root:root. An app running under a non-root user then fails to write, and configuration changes made in the UI silently do not persist across a restart. -
Check the identity before you chase the permission: run
docker exec stirling-pdf idto see which UID the process actually uses, thendocker exec stirling-pdf ls -ln /configsto compare it against the ownership of the mounted directory. The numbers either match or they do not, and that answers the question in five seconds. -
Set
PUIDandPGIDto your own account, not to 0: running as root to make a permission error go away leaves every file the app writes owned by root on your host, which becomes your problem the first time you try to back up or move the data directory.