Turning Paperless-ngx Into a Smart Filing Cabinet With Local AI and NAS Storage

Turning Paperless-ngx Into a Smart Filing Cabinet With Local AI and NAS Storage

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

  1. Add Gotenberg and Tika to Paperless (better document conversion and metadata extraction) (done)
  2. Install Ollama natively on Mac Mini M4 Max via Ansible (done)
  3. Mount TrueNAS NFS share for document storage (done, eventually)
  4. Deploy paperless-ai for auto-tagging, titles, and document classification (done)
  5. Deploy paperless-gpt for vision model OCR (done)
  6. 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:

  1. Mount the NFS share on the Proxmox host (not inside the LXC)
  2. Add a bind-mount in the LXC config that exposes it inside the container. mujo-nas.localdomain is 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.

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.