Paperless-ngx with Azure OCR, LLM Autofill, and a 59,486-Mail Backfill
Paperless-ngx on unRAID with Azure Document Intelligence OCR, an LLM hook that files every document, a PDF unlock hook, and a Proton Bridge mail backfill.
HomeLab, Self-hosted apps. Updated . 7 min read.
The hard part of a document archive is not OCR. It's that nothing gets filed. My first Paperless-ngx import produced 30 tags that mirrored my PC folders and nothing else, and that's exactly why it felt useless. Search worked. Everything else was a pile.
This time every document gets a real title, an issuer, a type, tags, an issue date, and an expiry date, without me touching it. OCR runs on Azure Document Intelligence, an LLM does the filing through LiteLLM, a pre-consume hook strips passwords off bank PDFs, and a Proton Bridge container fed it about 1,650 documents from my mailbox overnight.
TL;DR: Paperless-ngx with azureai remote OCR and archive generation set to always, a post-consume hook that asks an LLM for metadata and writes it back through the API, a pre-consume hook that unlocks PDFs with pikepdf, and a mail backfill that triaged 59,486 mails from BODYSTRUCTURE alone.
The install
Paperless runs as one container on the NUC, with its database in the shared Postgres I already had and a Valkey container as the broker (--save "" --appendonly no, no volume; it's a queue, not state). The split that matters is storage. data (index, classifier, logs) stays in appdata. media, consume and export live on the backup share, because the originals are my files, not app state, and they belong in the same Deep Archive pass as the rest of my PC backup.
Two settings came from pain. The first is PAPERLESS_TASK_WORKERS=3. One worker did about one document a minute during the import, because every file waits on the Azure round trip. Three did about eight. The second is polling every 30 seconds instead of inotify, because /mnt/user on unRAID is FUSE, and inotify on it is unreliable.
OCR on Azure, and the setting that makes it actually run
PAPERLESS_REMOTE_OCR_ENGINE=azureai
PAPERLESS_ARCHIVE_FILE_GENERATION=always
The model is prebuilt-read, and OCR mode is always. The trap is the second line. With the default auto, the parser trusts any existing text layer and skips Azure. Scanner-made PDFs often ship a garbage text layer, so my first receipt came out as noise until I set always. After that, Arabic and English scans came back correct. Tesseract eng+ara stays installed as the fallback if the engine variable is ever unset.
Every other AI call in my homelab goes through LiteLLM with a scoped key. OCR can't: Document Intelligence is an Azure SDK call, not an OpenAI-shaped API. So it's the one direct Azure call, with its key by name in the template.
The organisation pass
Before any automation, the archive needed a vocabulary. I ended up with 45 correspondents (the issuer), 24 document types, 22 tags (people, exactly one of Personal or Business, topics, trips), 11 storage paths that give the on-disk tree I wanted, like Identity/{document_type}/ and Business/{correspondent}/{created_year}/, and one custom date field, Expires.
Then a one-shot script sent every document (OCR text, original file name, old folder) to gpt-5.6-sol through LiteLLM with the vocabulary fixed in the prompt, got back JSON, and applied it through the API. 287 documents, no failures, 84 expiry dates set, and 88 issue dates corrected. The importer had taken birth dates off passports as the document date.
Two things the API won't do for you: bulk edits don't move files on disk, so manage.py document_renamer had to run afterwards. And merging a passport scanned as single-page JPGs into one PDF needs archive_fallback: true, because merge reads PDFs only.
Autofill: the post-consume hook
Built-in matching guesses correspondent, type and tags. Nothing writes a real title, fixes the date, or pulls the expiry. So a post-consume script runs inside the container after every document:
PAPERLESS_POST_CONSUME_SCRIPT=/usr/src/paperless/data/scripts/autofill.py
It fetches the live vocabulary from the API, sends the same prompt as the one-shot pass, and patches title, correspondent, type, storage path, tags (existing ones only), issue date and Expires. The Inbox tag stays, so I still glance at everything weekly.
The rule I care most about: the hook never exits non-zero. A non-zero exit marks the whole consumption task failed (run_post_consume_script in consumer.py). Instead, an error adds the tag Autofill failed, the task stays green, and a later good run clears the tag.
Two bugs showed up the first night:
- Two files consumed in the same second both tried to create the same new correspondent. The second got a 400 from the unique constraint. The hook now looks the name up on a 400 and carries on.
- The UI's own AI suggestions endpoint returned 500. Paperless sends
temperature, and the gpt-5.6 reasoning models reject it. I fixed it in LiteLLM, not Paperless:
drop_params: true
additional_drop_params: ["temperature"]That goes on each of the three gpt-5.6 model entries.
One more trap: switching the embedding model from text-embedding-3-small to -large needs manage.py document_llmindex rebuild, because the vector index keeps the dimension it was created with.
Unlocking PDFs before OCR
Paperless has no password support. Upstream discussions 510, 1141 and 4026 all end at "use a pre-consume script". Without one, a locked PDF is stored with no text. Worse, a mail attachment whose consumption failed is marked processed and never fetched again. It had to exist before the backfill.
The hook opens DOCUMENT_WORKING_PATH with pikepdf (also in the image). If it's encrypted, it tries each line of a password file and saves in place:
with pikepdf.open(path, password=pw, allow_overwriting_input=True) as pdf:
pdf.save(path, deterministic_id=True)deterministic_id=True matters: the same input gives the same output, so Paperless's checksum duplicate detection still works on a re-import. The password file lives in the mounted data folder, mode 600, never in the container template. The log names the candidate number, never the value:
unlock: unlocked with candidate #8 of 10
Same rule as autofill: it always exits 0.
The mail backfill through Proton Bridge
Proton Mail has no plain IMAP, so a Proton Bridge container on the NUC exposes it, IMAP only. I removed the SMTP port mapping: nothing on the LAN gets send access. Paperless has one mail rule on the folder Labels/Paperless (Bridge exposes every label as Labels/<name>) with the action Flag. Mark-as-read would skip mails I'd already read before labelling, and Move would pull them out of my Inbox.
The libfido2 image
The popular shenxn/protonmail-bridge image is stale. Bridge updates itself inside the mounted /root, and the updated binary needs a library the image doesn't have. A recreate fails at boot:
libfido2.so.1: cannot open shared object file
The fix is a two-line image, built on the NUC and pushed to my local registry:
FROM shenxn/protonmail-bridge:latest
RUN apt-get update && apt-get install -y --no-install-recommends libfido2-1 && rm -rf /var/lib/apt/lists/*The next missing library will show the same log line, and the fix is one more package.
The faketty fifo
Bridge reads its CLI from a fifo at /protonmail/faketty. The obvious echo info > faketty opens the pipe, writes, and closes it. Bridge sees end of input and exits, and the restart policy brings it back with the sync interrupted. The writer has to stay open:
docker exec -d protonmail-bridge sh -c \
"exec 3>/protonmail/faketty; echo info >&3; exec sleep infinity"Then read the answer from docker logs.
Triage with BODYSTRUCTURE
The NUC bridge needed about three hours for its first sync, so the backfill ran through the Bridge on my PC. WSL in mirrored networking mode reaches 127.0.0.1:1143 on Windows directly.
I didn't want to download 59,486 mails to find the ones with documents. IMAP BODYSTRUCTURE describes every MIME part, type and file name included, without the content. One read-only walk of All Mail, fetching BODYSTRUCTURE and three header fields, took about a minute and left 1,761 mails with a PDF, an attached image, or an office file.
Sol sorted those from headers and file names into 15 categories, in batches of 120. I picked the keepers: statements, contracts, invoices, identity, employment and similar. Shop receipts, noise, and per-transaction bank notices stayed out. 1,009 mails got the label through IMAP COPY. In Proton a label is a folder copy, so the mail stays in All Mail.
Paperless went from 345 to 2,002 documents by 07:05 the next morning, each one through Azure, the autofill hook, and the LLM index. That night's LiteLLM spend was real.
One gotcha on the NUC side: Paperless logged Folder Labels/Paperless does not exist on every fetch. The NUC bridge learned its folder list when the first sync started, before the label existed. It catches up on label events after the sync. A restart is the fallback.
Statements that arrive as a link
One card issuer doesn't attach statements at all. The mail carries a link to a page that asks for the statement password and answers with the PDF as base64. So a small monthly script reads those mails over IMAP, follows each link it hasn't seen (HTTPS only, no redirects), posts the password, and uploads the PDF to Paperless, where autofill files it. State is a file of processed Message-IDs, and a rerun is safe anyway, because Paperless rejects a duplicate by checksum. It pushes an Uptime Kuma heartbeat so a silent miss shows up. The backfill pulled 13 statements, and every old link still worked.
The backup that broke
The first nightly backup after the mail import failed with file changed as we read it. The LLM index is a 279 MB SQLite file that Paperless rewrites after every document save, and the import was still running at 03:00. Paperless moved to the stop-and-verify backup class. The stop costs about 20 seconds a night.
Questions
- Can Paperless-ngx use Azure Document Intelligence for OCR?
- Yes. Set PAPERLESS_REMOTE_OCR_ENGINE=azureai with the resource endpoint and key, and use the prebuilt-read model. Also set PAPERLESS_ARCHIVE_FILE_GENERATION=always, or Paperless trusts an existing text layer on scanner PDFs and never sends them to Azure.
- How do I import password-protected PDFs into Paperless-ngx?
- Paperless has no password support. Use a pre-consume script that opens the working file with pikepdf, tries each candidate password from a file outside the container template, and saves the PDF unlocked in place with deterministic_id=True so duplicate detection still works. The script should never exit non-zero.
- Why does the Proton Bridge container fail with libfido2.so.1: cannot open shared object file?
- Bridge updates itself inside the mounted /root volume, and newer Bridge versions need libfido2, which the old upstream image does not ship. A two-line Dockerfile that adds the libfido2-1 Debian package on top of the upstream image fixes it.
- How do I find mail attachments over IMAP without downloading every mail?
- Fetch BODYSTRUCTURE plus a few header fields for every message. BODYSTRUCTURE describes each MIME part, including its type and file name, without the content, so you can pick the mails with PDFs, images, or office files and fetch only those.