The Itch
I had Paperless-ngx running. Technically. It was deployed on Portainer, had a DNS entry, a TLS cert, the whole nine yards. It also had zero documents in it. It was a ghost town with a login page. Every time I'd get a medical bill, a receipt, or a tax document, I'd do what every responsible adult does: take a photo on my phone and forget which album it ended up in.
The straw that broke it was tax season. Every year, my wife and I spend hours across multiple days going through all of her business bank account statements, manually categorizing every expense. Is this one deductible? Was that a business lunch or a personal one? What about that $47 charge from November that neither of us remembers? It's tedious, error prone, and we both dread it. The whole time I'm thinking: I have a rack of computers in the other room. There has to be a better way. That's when I decided to actually use Paperless. Not just run it. Use it. The goal is simple: upload receipts and invoices throughout the year, let AI tag them as tax-deductible or not, and when tax season comes around, just filter by the tag instead of excavating bank statements.
But basic Paperless with its built-in OCR was not going to cut it. I'd seen what happens when you feed it a camera photo of a receipt. Tesseract (the OCR engine) does its best, but "its best" for a photo of crumpled paper under fluorescent lighting is a string of characters that looks like someone fell asleep on a keyboard. I wanted the documents to be searchable, auto categorized, and ideally smart enough to tag a medical bill as "medical" and a hosting invoice as "tax-deductible" without me lifting a finger.
The Inspiration
I watched Techno Tim's video where he set up Paperless-ngx with Ollama and a couple of AI add-ons that dramatically improved both the metadata and the OCR quality. Great video. But his setup runs everything in one big Docker Compose stack on a single Linux box with an NVIDIA GPU. My homelab is a bit more distributed, so the architecture had to adapt.
| Techno Tim's Setup | My Setup |
|---|---|
| Single Linux server with NVIDIA GPU | LXC container (Portainer) + Mac Mini M4 Max |
| Everything in one Docker Compose | Services split across hosts |
| Ollama in Docker (NVIDIA GPU passthrough) | Ollama native on macOS (Metal GPU, no Docker) |
| Local storage | TrueNAS NAS via NFS |
The big deviation: Docker on Apple Silicon cannot pass through the GPU to containers. So Ollama has to run natively on macOS to use the M4 Max's Metal acceleration. Everything else stays containerized.
The Plan
Add Gotenberg and Tika to Paperless (better document conversion and metadata extraction)(done)Install Ollama natively on Mac Mini M4 Max via Ansible(done)Mount TrueNAS NFS share for document storage(done, eventually)Deploy paperless-ai for auto-tagging, titles, and document classification(done)Deploy paperless-gpt for vision model OCR(done)Tune the AI prompt for tax-deductible and medical document detection(done)
The Tech Stack
Think of it like this: Paperless-ngx is the filing cabinet, Ollama is the brain running on a separate machine, and two AI add-ons (paperless-ai and paperless-gpt) sit in between making everything smarter.
Paperless-ngx stores, indexes, and searches documents. It runs built-in OCR via Tesseract and handles the web UI where you interact with everything.
Gotenberg converts non-PDF documents (Word, Excel, HTML, emails) into PDFs so Paperless can process them. Without it, you're limited to PDFs and images.
Tika (Apache Tika) extracts metadata and text from various file formats. It reads the guts of Office docs, emails, and other formats so Paperless can index them.
Ollama runs the actual LLM models. It sits on the M4 Max, listening on port 11434, serving two models: qwen3:8b for text processing and minicpm-v:8b for vision (understanding images).
paperless-ai reads the text content that Paperless already extracted and feeds it to the LLM. The LLM assigns tags, generates titles, detects correspondents (who sent it), and classifies the document type. It's the smart filing clerk.
paperless-gpt replaces the OCR itself. Instead of Tesseract guessing at pixels, it feeds the document image to a vision model (minicpm-v) that actually understands what it's looking at. Tables become tables, serial numbers stay intact, and blurry text gets read correctly.
Here's how it all connects:
Document uploaded
→ Paperless-ngx ingests + basic OCR (Tesseract)
→ Gotenberg converts non-PDFs
→ Tika extracts metadata
→ paperless-ai reads text → sends to Ollama (qwen3:8b) → writes back tags/title/type
→ paperless-gpt reads image → sends to Ollama (minicpm-v:8b) → replaces OCR content
Why Ollama Can't Run in Docker on Apple Silicon
This tripped me up. Techno Tim runs Ollama in Docker with --gpus all because he has an NVIDIA GPU where Docker GPU passthrough just works. On Apple Silicon, Docker Desktop runs a Linux VM under the hood, and that VM has no access to the Metal GPU. Running Ollama in Docker on a Mac means CPU-only inference, which is painfully slow for any useful model.
The solution: install Ollama natively on macOS. It automatically uses Metal for GPU acceleration, and the M4 Max's unified memory architecture means models load fast and run fast. I got 48 tokens/second on minicpm-v:8b, which is plenty for document processing.
I wrote an Ansible playbook to automate the install, configure it to listen on all interfaces (so containers on other hosts can reach it), and pull the models:
# ansible/playbook-ollama.yml (abbreviated)
- name: Install and configure Ollama on macOS (Apple Silicon)
hosts: mac_ai
tasks:
- name: Install Ollama via Homebrew
community.general.homebrew:
name: ollama
state: present
- name: Create Ollama LaunchAgent plist
ansible.builtin.template:
src: templates/com.ollama.serve.plist.j2
dest: "{{ ansible_facts.env.HOME }}/Library/LaunchAgents/com.ollama.serve.plist"
- name: Pull qwen3:8b model
ansible.builtin.command: /opt/homebrew/bin/ollama pull qwen3:8b
- name: Pull minicpm-v:8b vision model
ansible.builtin.command: /opt/homebrew/bin/ollama pull minicpm-v:8b
The LaunchAgent plist configures OLLAMA_HOST=0.0.0.0:11434 so other machines on the LAN can reach it, and KeepAlive=true so it auto-restarts if it crashes:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ollama.serve</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/ollama</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>OLLAMA_HOST</key>
<string>0.0.0.0:11434</string>
<key>OLLAMA_KEEP_ALIVE</key>
<string>15m</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
NFS Storage: The LXC Plot Twist
I wanted documents stored on my TrueNAS NAS, not on the Portainer host's local disk. NAS gives me ZFS snapshots, redundancy, and one place to back up. I'd already worked with NFS in the past, so I figured this would be quick. It was not quick.
The Paperless container runs on Portainer, which is an unprivileged LXC container on Proxmox. And unprivileged LXC containers cannot mount filesystems. The mount() syscall is blocked at the kernel level, even as root inside the container. I spent a while tweaking NFS export settings on TrueNAS before realizing the request was never leaving the container.
The Fix: Mount on Proxmox, Bind-Mount Into LXC
The standard pattern for LXC + NFS:
- Mount the NFS share on the Proxmox host (not inside the LXC)
- Add a bind-mount in the LXC config that exposes it inside the container.
mujo-nas.localdomainis DNS for my NAS.
On Proxmox:
# /etc/fstab on Proxmox host
mujo-nas.localdomain:/mnt/Storage/paperless /mnt/nas/paperless nfs vers=3,nolock,soft,rw,_netdev 0 0
In the LXC config (/etc/pve/lxc/100.conf):
mp0: /mnt/nas/paperless,mp=/mnt/nas/paperless
One catch with unprivileged LXCs: UID namespacing. Container UID 1000 maps to host UID 101000, so files created by the container appear as a different user on the host. The fix is to chmod 777 the NFS subdirectories (media, export, consume). This is fine because NFS access is already controlled at the network level by the share configuration.
The Paperless compose just points at the bind-mounted paths:
volumes:
# data stays local (search index, classifier cache)
- /opt/paperless/data:/usr/src/paperless/data
# media/export/consume on NFS (TrueNAS → Proxmox → LXC bind mount)
- /mnt/nas/paperless/media:/usr/src/paperless/media
- /mnt/nas/paperless/export:/usr/src/paperless/export
- /mnt/nas/paperless/consume:/usr/src/paperless/consume
Data stays local for performance (search index, classifier cache). The actual documents live on the NAS.
The Compose Files
Everything follows my standard homelab convention: each service gets its own directory under docker/, its own docker-compose.yml, a .env.example with placeholders, and real secrets in .env on the host (never committed).
Paperless-ngx + Gotenberg + Tika
services:
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
container_name: paperless
restart: unless-stopped
network_mode: host
depends_on:
- gotenberg
- tika
volumes:
- /opt/paperless/data:/usr/src/paperless/data
- /mnt/nas/paperless/media:/usr/src/paperless/media
- /mnt/nas/paperless/export:/usr/src/paperless/export
- /mnt/nas/paperless/consume:/usr/src/paperless/consume
env_file: .env
environment:
PAPERLESS_REDIS: redis://localhost:6379
PAPERLESS_DBHOST: localhost
PAPERLESS_DBNAME: paperless
PAPERLESS_DBUSER: paperless
PAPERLESS_TIME_ZONE: America/New_York
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_URL: https://paperless.localdomain
PAPERLESS_PORT: 28981
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://localhost:3100
PAPERLESS_TIKA_ENDPOINT: http://localhost:9998
PAPERLESS_TASK_WORKERS: 5
healthcheck:
test: ["CMD", "curl", "-fs", "-S", "-L", "--max-time", "2", "http://localhost:28981"]
interval: 30s
timeout: 10s
retries: 5
gotenberg:
image: docker.io/gotenberg/gotenberg:8.27
container_name: gotenberg
restart: unless-stopped
network_mode: host
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
- "--api-port=3100"
environment:
TZ: America/New_York
tika:
image: docker.io/apache/tika:latest
container_name: tika
restart: unless-stopped
network_mode: host
environment:
TZ: America/New_York
Gotenberg runs on port 3100 (not the default 3000) to avoid a conflict with another service.
paperless-ai
services:
paperless-ai:
image: clusterzx/paperless-ai:latest
container_name: paperless-ai
restart: unless-stopped
ports:
- "3003:3000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file: .env
environment:
TZ: America/New_York
PAPERLESS_AI_PORT: 3000
PAPERLESS_API_URL: http://192.168.1.2:28981/api
PAPERLESS_URL: http://192.168.1.2:28981
AI_PROVIDER: ollama
OLLAMA_API_URL: http://192.168.1.53:11434
OLLAMA_MODEL: qwen3:8b
RAG_SERVICE_URL: http://192.168.1.2:28981
RAG_SERVICE_ENABLED: "true"
SCAN_INTERVAL: "*/2 * * * *"
volumes:
- /opt/paperless-ai/data:/app/data
The scan interval is 2 minutes. Every 2 minutes, paperless-ai checks for new documents and runs them through the LLM. Initially I had it at 30 minutes (the default) and kept wondering why nothing was happening after uploads.
paperless-gpt
services:
paperless-gpt:
image: icereed/paperless-gpt:latest
container_name: paperless-gpt
restart: unless-stopped
ports:
- "3002:8080"
env_file: .env
environment:
TZ: America/New_York
PAPERLESS_BASE_URL: http://192.168.1.2:28981
LLM_PROVIDER: ollama
LLM_MODEL: llama3.2:3b
OLLAMA_HOST: http://192.168.1.53:11434
OLLAMA_CONTEXT_LENGTH: "8192"
TOKEN_LIMIT: "1000"
LLM_LANGUAGE: English
OCR_PROVIDER: llm
VISION_LLM_PROVIDER: ollama
VISION_LLM_MODEL: minicpm-v:8b
AUTO_OCR_TAG: paperless-gpt-ocr-auto
AUTO_TAG: paperless-gpt-auto
MANUAL_TAG: paperless-gpt-manual
PDF_OCR_TAGGING: "true"
PDF_OCR_COMPLETE_TAG: paperless-gpt-ocr-complete
PDF_UPLOAD: "false"
LOG_LEVEL: DEBUG
volumes:
- /opt/paperless-gpt/prompts:/app/prompts
Both paperless-ai and paperless-gpt use bridge networking with port mapping instead of host networking. This was necessary because their default ports (3000 and 8080) were already taken by other services on the Portainer host. Neither port is configurable via environment variable, which I discovered after watching them crash-loop with EADDRINUSE errors.
The Prompt Engineering Rabbit Hole
This is where I spent more time than I expected. The default paperless-ai prompt does a decent job at generating titles and detecting correspondents, but it was ignoring my specific requirements: tagging documents as "tax-deductible" or "medical."
The first attempt was a long, detailed prompt with all the rules. The small model (llama3.2:3b) would fill all 4 tag slots with descriptive tags like "Web Hosting" and "Invoice" and leave no room for "tax-deductible." Upgrading to qwen3:8b helped, but the real fix was restructuring the prompt.
What worked: making the classification tags mandatory and putting them before the descriptive tags. Instead of "include the tag tax-deductible if applicable" buried in a paragraph, I made it:
Tag rules (CRITICAL — follow exactly):
- Start with mandatory tags, then add up to 3 descriptive tags.
- MANDATORY: If the expense is potentially tax-deductible (...), you MUST include the tag "tax-deductible".
- MANDATORY: If the document is healthcare-related (...), you MUST include the tag "medical".
- MANDATORY: If the document is a personal identity or life document, you MUST include the tag "personal documents".
- Then add up to 3 descriptive tags for the document topic.
The word "MANDATORY" and "MUST" in caps, plus front-loading the rules before the descriptive tags, made the model consistently apply them. Small models respond better to structure and emphasis than to nuance.
I also found that giving the model explicit examples of what qualifies as tax-deductible (business expenses, software subscriptions, tolls, web hosting, domain renewals) produced much better results than just saying "business expenses." The model doesn't have your tax accountant's intuition. Spell it out.
Performance on Apple Silicon
The M4 Max handles this workload comfortably:
| Model | Speed | Use Case |
|---|---|---|
| qwen3:8b | ~41 tokens/s generation | paperless-ai (text classification) |
| minicpm-v:8b | ~48 tokens/s generation | paperless-gpt (vision OCR) |
First inference after model load takes ~20 seconds (cold load into GPU memory), but subsequent requests are near-instant because OLLAMA_KEEP_ALIVE=15m keeps the model warm. For document processing (not real-time chat), this is more than fast enough.
On the Paperless side, setting PAPERLESS_TASK_WORKERS=5 lets it OCR multiple documents in parallel. HEIC photos from an iPhone take a couple of minutes each through Tesseract, so parallelism helps when you batch-upload a stack of receipts.
What I Learned
This project changed how I interact with paper documents. I take a photo, drag it into Paperless, and within a couple of minutes it's filed, tagged, searchable, and backed up on the NAS. Tax season next year should be a filter query instead of a camera roll excavation.
The biggest lesson: Ollama on Apple Silicon is genuinely good. Something that technically works but isn't practical, claude, chatgpt and other models are far more superior. The M4 Max runs these models fast enough that document processing feels near-instant. The unified memory architecture means even the 8B parameter models load without drama. If you have a Mac collecting dust, this is a great use for it.
The second lesson: prompt engineering matters more than model size. Switching from a 3B to an 8B model helped, but restructuring the prompt to front-load mandatory rules was the bigger improvement. Small local models are capable, but you have to be explicit. They don't infer what you want from context the way larger cloud models do.
The third lesson: LXC containers and NFS don't mix directly. If you're running Docker inside an unprivileged LXC on Proxmox and you need NAS storage, mount on the Proxmox host and bind-mount into the LXC. Don't spend an hour tweaking NFS export settings on TrueNAS like I did. The mount() syscall is blocked inside the container. No amount of NFS flags will fix a kernel-level restriction.
What's Next: Automated Tax Categorization
Right now, the AI tags documents as "tax-deductible" or not. That's already a huge win. But "tax-deductible" is a broad bucket. When my wife's CPA asks for expenses broken down by category, we still have to sort through the pile manually.
If you're wondering why doesn't she just use quickbooks or whatever. She started last year but there is still figuring out on her, what works best for her expense-wise. Sometimes she ends up paying business expense with personal card or cash, and it's important to categorize those correctly. I would say having quickbooks makes this 80% easier than previous few years. Even tho remaining 20% isn't as big it's definitely the most annoying.
The v2 vision is a second step in the pipeline that takes everything tagged "tax-deductible" and classifies it into one of the actual IRS expense categories we use:
| Category | Category | Category |
|---|---|---|
| Advertising | Mileage | Sunpass |
| Parking & Tolls | Bank Charges | Computer & Internet |
| Client Reimbursement | Dues & Subs | Education/Coach |
| Gifts | Insurance (Health) | Insurance (Other) |
| Interest | Legal & Prof. | Meals & Ent. |
| Office Exp. | Postage | Assistant |
| Repairs & Maint. | Supplies | Taxes & Licenses |
| Telephone | Travel | Staging |
| Utilities | Pension Exp (SEP/SIMPLE) | Photography |
| Officer's Salary | Other Wages | Payroll Taxes |
These are the exact column headers from the spreadsheet we use today. The dream is to upload a receipt, have the AI tag it as tax-deductible, then automatically sub-categorize it into "Computer & Internet" or "Meals & Ent." or "Travel." At year end, export a filtered view grouped by category and hand it straight to the CPA. No more multi-day spreadsheet sessions.
This is probably a custom field or a second tag layer in paperless-ai, combined with a more specific prompt. Or maybe a small script that runs after paperless-ai processing and does a second pass with a more focused prompt. Either way, the infrastructure is all in place now. The hard part is done. The categorization logic is just prompt engineering.
Featured Image Prompt
I used this prompt to generate the featured image. A cozy home office desk with a stack of messy paper documents on one side being fed into a glowing portal, emerging on the other side as neatly organized digital files floating in a holographic display. A small robot assistant with a magnifying glass is examining each document. The desk has a Mac Mini and a NAS box with blinking lights in the background. Warm lighting, isometric perspective, clean vector illustration style with a slight retro-tech aesthetic.