DEV Community

DeployLark
DeployLark

Posted on Originally published at deploylark.com

Paperless-ngx Docker Compose Setup with OCR

Paperless-ngx turns a pile of paper into a searchable archive. You scan a letter or drop a PDF into a folder, and Paperless runs OCR on it, stores the original plus a searchable PDF/A copy, and makes every word findable. It's one of the most popular self-hosted apps. On October 15, 2026, the GitHub repository had about 46,700 stars, and the paperlessngx/paperless-ngx image had been pulled about 38 million times from Docker Hub. That figure leaves out GitHub's registry, which the official compose files use by default.

The official compose files get you running, but they leave three traps for a new install. They ship a placeholder secret key that v3 refuses to start with, they publish the web UI on every interface before you've created the admin account, and they say little about OCR languages, which decide whether "Müller" ends up in your archive as "Muller". This guide fixes all three. I ran every step below on a test box with Paperless-ngx 3.3.1, OCR'd scans I generated in English, German and Polish, imported a Word file and an email attachment, then tested a backup and an upgrade.

TL;DR:

  • Start from the official docker-compose.postgres-tika.yml, pin every image tag, bind the port to 127.0.0.1:8000, and put a random PAPERLESS_SECRET_KEY and database password in a .env file.
  • Open the web UI right away. The first account you sign up with becomes the superuser.
  • PAPERLESS_OCR_LANGUAGE picks the OCR languages (deu+eng). PAPERLESS_OCR_LANGUAGES installs extra ones (pol). English, German, French, Italian and Spanish are already in the image.
  • Drop files into ./consume or add an IMAP account and a mail rule. Back up with document_exporter --passphrase, and upgrade by changing the tag and running docker compose up -d.

What you need and the compose file

The five containers

Paperless-ngx in Docker is one app container plus helpers. The diagram shows the stack this guide builds:

Diagram of the Paperless-ngx stack in one Docker Compose project. A browser (via SSH tunnel or proxy), the ./consume folder bind-mounted from the host and an IMAP mailbox polled by mail rules all feed the webserver container, which runs Paperless-ngx 3.3.1 with OCR and is published on 127.0.0.1:8000. The webserver talks to db (PostgreSQL 18.6, the database), broker (Valkey 9.1.2, task queue), tika (Apache Tika 3.3.1.0, Office text) and gotenberg (Gotenberg 8.37.0, Office to PDF). Data lives in the named volumes data, media, pgdata and redisdata.

  • webserver is Paperless itself. The image bundles the web UI, the background workers, the consume folder watcher and the OCR tools. In 3.3.1 that was Tesseract 5.5.0 and OCRmyPDF 17.12.1.
  • db is PostgreSQL. The setup docs recommend it for new installs. SQLite and MariaDB also work.
  • broker is Valkey, a Redis-compatible store that queues background tasks.
  • tika and gotenberg are optional. They let Paperless read Office files such as .docx, .xlsx and .odt, plus .eml email files. Without them, it handles PDFs, images and plain text.

You need a Linux host with Docker and the Compose plugin. The project doesn't publish minimum hardware. On my 8-core test box, after all the tests below, the five containers used about 1.3 GB of RAM together, and most of that was Paperless itself (825 MB) and Tika (261 MB). The setup docs have a section for low-powered devices like a Raspberry Pi, with settings such as PAPERLESS_OCR_PAGES=1 that cut the OCR work.

The compose file and secrets

The official files live in the repo's docker/compose folder. This is docker-compose.postgres-tika.yml from the v3.3.1 tag with four changes. Every image is pinned to the exact version I tested (upstream uses latest for Paperless and tags like postgres:18 and valkey:9-alpine that move with each release), the port listens on 127.0.0.1 only, and the database password and secret key come from .env instead of being hard-coded.

mkdir -p ~/paperless/consume ~/paperless/export && cd ~/paperless
umask 077
cat > .env <<EOF
COMPOSE_PROJECT_NAME=paperless
POSTGRES_PASSWORD=$(openssl rand -hex 24)
PAPERLESS_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(64))')
EOF
umask 022
Enter fullscreen mode Exit fullscreen mode

umask 077 makes the new .env readable only by you. Compose reads .env automatically and fills in the ${...} values below. Keep COMPOSE_PROJECT_NAME=paperless, because upstream's .env sets it too, and it decides the volume names (paperless_media and so on).

services:
  broker:
    image: docker.io/valkey/valkey:9.1.2-alpine
    restart: unless-stopped
    volumes:
      - redisdata:/data
  db:
    image: docker.io/library/postgres:18.6
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:3.3.1
    restart: unless-stopped
    depends_on:
      - db
      - broker
      - gotenberg
      - tika
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - data:/usr/src/paperless/data
      - media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume
    env_file: docker-compose.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
      PAPERLESS_DBENGINE: postgresql
      PAPERLESS_DBPASS: ${POSTGRES_PASSWORD}
      PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY}
      PAPERLESS_TIKA_ENABLED: 1
      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
  gotenberg:
    image: docker.io/gotenberg/gotenberg:8.37.0
    restart: unless-stopped
    command:
      - "gotenberg"
      - "--chromium-disable-javascript=true"
      - "--chromium-allow-list=^file:///tmp/.*"
  tika:
    image: docker.io/apache/tika:3.3.1.0
    restart: unless-stopped
volumes:
  data:
  media:
  pgdata:
  redisdata:
Enter fullscreen mode Exit fullscreen mode

Save it as docker-compose.yml. The Gotenberg flags are upstream's. Gotenberg's Chromium converts .eml files, and the compose file's own comment says the flags stop it from loading external content such as tracking pixels or JavaScript.

The PostgreSQL volume is mounted at /var/lib/postgresql, not /var/lib/postgresql/data. That's correct for PostgreSQL 18. The 18.6 image sets PGDATA=/var/lib/postgresql/18/docker, so the database sits in a versioned folder inside the volume.

Next, docker-compose.env holds the Paperless settings. Upstream's copy contains PAPERLESS_SECRET_KEY=change-me. Don't keep that line. This is the file I ended up with:

USERMAP_UID=1000
USERMAP_GID=1000
PAPERLESS_TIME_ZONE=Europe/Berlin
PAPERLESS_OCR_LANGUAGE=deu+eng
#PAPERLESS_OCR_LANGUAGES=pol
#PAPERLESS_URL=https://paperless.example.com
Enter fullscreen mode Exit fullscreen mode

Set USERMAP_UID and USERMAP_GID to the output of id -u and id -g, so the container can read and delete what you put in ./consume. Set PAPERLESS_TIME_ZONE to your own zone, since it defaults to UTC. The OCR and URL lines are explained below.

Why the secret key matters: in v3 it's required. The configuration docs say "Paperless will refuse to start if this is not set." It refuses the placeholder too. When I started 3.3.1 without a key, it stopped with:

django.core.exceptions.ImproperlyConfigured: PAPERLESS_SECRET_KEY is not set or is the default 'change-me' value. A unique, secret key is required for secure operation. Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(64))"
Enter fullscreen mode Exit fullscreen mode

First start and the superuser

Start the stack

docker compose pull
docker compose up -d
docker compose logs -f webserver
Enter fullscreen mode Exit fullscreen mode

With the images already pulled, my first start took about 35 seconds from up -d to a working login page. The log runs the database migrations and ends with lines like these:

[init-complete] paperless-ngx docker container init completed in 22 seconds
[INFO] Listening at: http://:::8000
[paperless.management.consumer] Watching /usr/src/paperless/consume using native file system events
Enter fullscreen mode Exit fullscreen mode

Port 8000 now only answers on the server itself. Reach it with an SSH tunnel (ssh -L 8000:127.0.0.1:8000 you@server, then open http://localhost:8000), or put a reverse proxy in front. The Nginx Proxy Manager guide covers a web UI proxy with a free certificate, and the Umami guide shows the same 127.0.0.1 binding behind Caddy.

Create the admin account

There's no default login. On a fresh install, the first visit redirects to a sign-up page:

Paperless-ngx 3.3.1 sign-up page on a fresh install, with the note: This is the first user account for this installation and will be granted superuser privileges. Fields for username, optional email, password and password again, and a Sign up button.

That note is the reason for the 127.0.0.1 binding. Whoever reaches this page first owns every document. Upstream's "8000:8000" publishes the port on all interfaces, and Docker's firewall docs explain that published ports skip ufw's rules. The Open WebUI guide runs into the same first-account problem. Create your account as soon as the stack is up.

You can also have the container create it. Per the configuration docs, PAPERLESS_ADMIN_USER and PAPERLESS_ADMIN_PASSWORD create a superuser at start, and they won't change an existing user's password. On a test stack, with the values passed through environment: like the secret key, the log printed [init-superuser] Creating superuser... and logging in with them worked.

The setup docs also suggest making a separate everyday user, since a superuser can see and change everything.

OCR languages that actually work

PAPERLESS_OCR_LANGUAGE vs PAPERLESS_OCR_LANGUAGES

The two names differ by one letter and do different jobs:

  • PAPERLESS_OCR_LANGUAGE (singular) tells Tesseract which language or languages to read. It takes three-letter codes joined with +, such as deu+eng. It defaults to eng.
  • PAPERLESS_OCR_LANGUAGES (plural) is Docker only. It installs extra language packs when the container starts, as a space-separated list such as pol tur.

The image already contains English, German, French, Italian and Spanish. tesseract --list-langs inside my 3.3.1 container showed deu, eng, fra, ita, spa and osd (orientation detection), plus pol once I added it.

To see why the singular setting matters, I generated a scanned German letter with no text layer and fed it in twice. Here's what Paperless stored:

Terminal capture comparing the OCR text Paperless-ngx 3.3.1 stored for the same scanned German letter. With PAPERLESS_OCR_LANGUAGE=eng the lines read Frau Muller, fur 2025, andert sich and Mit freundlichen Griffen. With PAPERLESS_OCR_LANGUAGE=deu+eng they read Frau Müller, für 2025, ändert sich and Mit freundlichen Grüßen.

With eng, every umlaut was dropped and "Grüßen" became "Griffen". The search index forgave some of that. A search for "Müller" found both copies. A search for "Grüßen" found only the one read with deu+eng. Set the language before you import your archive, because documents you've already added keep their old text unless you reprocess them.

For a language that isn't in the image, you need both settings. First I set only PAPERLESS_OCR_LANGUAGE=pol+eng. The container refused to finish starting and restarted over and over with:

?: The selected ocr language pol is not installed. Paperless cannot OCR your documents without it. Please fix PAPERLESS_OCR_LANGUAGE.
Enter fullscreen mode Exit fullscreen mode

Adding the install line fixed it:

PAPERLESS_OCR_LANGUAGE=pol+eng
PAPERLESS_OCR_LANGUAGES=pol
Enter fullscreen mode Exit fullscreen mode

The log showed [init-tesseract-langs] Installing package tesseract-ocr-pol... and then Installed tesseract-ocr-pol. A scanned Polish letter then came back with "Urząd Skarbowy w Łodzi", "Żeromskiego" and "1 234,56 zł" intact.

Two things to know about that install. It downloads the Debian package when the container starts, so the container needs internet access then. And it happens again every time the container is recreated. After a plain restart the log said Package tesseract-ocr-pol already installed!, but after docker compose up -d with changed settings it downloaded the package again. The docs add that this option doesn't work in rootless containers. Package names use hyphens where Tesseract codes use underscores, so traditional Chinese is chi-tra in PAPERLESS_OCR_LANGUAGES and chi_tra in PAPERLESS_OCR_LANGUAGE. When a user asked how to install a language pack by hand, maintainer stumpylog replied: "You don't do it manually with Docker." Use the environment variable.

Every language you add costs time. The docs warn that "Tesseract uses much more CPU time with multiple languages enabled," so list only the ones your documents use.

Other OCR settings worth knowing

The defaults are sensible. These are the ones you're most likely to change, as described in the configuration docs:

  • PAPERLESS_OCR_MODE defaults to auto. Paperless checks whether a PDF already has text and skips OCR if it does. redo replaces existing text layers, which helps when your scanner's own OCR is poor. force rasterizes everything first. off never runs OCR.
  • PAPERLESS_ARCHIVE_FILE_GENERATION decides when a PDF/A copy is made. The default auto makes one for scans and image PDFs and skips born-digital PDFs.
  • PAPERLESS_OCR_PAGES=1 OCRs only the first page, which the docs suggest for slow hardware.
  • PAPERLESS_OCR_CLEAN runs unpaper before OCR. The default is clean. The setup docs say none speeds up OCR and uses less memory, at the cost of slightly worse results.

Version 3 removed some older OCR settings, including PAPERLESS_OCR_MODE=skip and PAPERLESS_OCR_SKIP_ARCHIVE_FILE. The v3 migration guide maps each one to its replacement, and old values aren't honored, so check any settings you copy from an older guide.

OCR is good, not perfect. On my test invoice, the invoice number, both dates and the total came out exactly, and a search for RP-2026-0457 found the document. Two line items with dotted leaders (Labour, 2 hours ......) came out garbled. Expect to fix titles and dates by hand now and then, and keep the original file, which Paperless always does.

Getting documents in: consume folder and email

The consume folder

Anything you put in ./consume gets imported and then removed from the folder. When I copied the English scan in, the watcher waited until the file had stopped changing (the default stability delay is 5 seconds), and the import itself took about 7 seconds:

[paperless.management.consumer] File is stable: /usr/src/paperless/consume/invoice-scan-en.pdf
[ocrmypdf._pipelines._common] Output file is a PDF/A-2b (as expected)
[paperless.consumer] Document 2026-10-03 invoice-scan-en consumption finished
Enter fullscreen mode Exit fullscreen mode

Paperless also read the date from the text. The document got 2026-10-03, the invoice date printed on it. Open any document and the Content tab shows the text OCR produced, next to a preview:

Paperless-ngx document detail page for the German test letter, with the Content tab open on the left showing the OCR text including Frau Müller and Mit freundlichen Grüßen, and the scanned page preview on the right.

With Tika and Gotenberg running, Office files work the same way. A .docx I dropped in went to Tika for its text, then to Gotenberg for a PDF copy. One catch: Paperless took the document date from the file's metadata. My generated test file carried a 2013 creation date from its template, so the document was filed under December 24, 2013. Check dates on Office files.

Scanners and sync tools often write files as a different user. To see what that looks like, I put a file in ./consume owned by root with mode 600. Paperless runs as UID 1000, so it logged:

[ERROR] [paperless.management.consumer] Error while queuing document /usr/src/paperless/consume/root-owned.pdf
PermissionError: [Errno 13] Permission denied: '/usr/src/paperless/consume/root-owned.pdf'
Enter fullscreen mode Exit fullscreen mode

After sudo chown 1000:1000 on the file, Paperless picked it up again by itself and imported it. If your scanner writes to the folder over the network, make sure the files arrive owned by the UID in USERMAP_UID, or readable by it.

If the folder is on NFS or an SMB share, Paperless may never notice new files, because those file systems often don't send change notifications. The configuration docs say to set PAPERLESS_CONSUMER_POLLING_INTERVAL to a number of seconds in that case. That's the v3 name. Older guides call it PAPERLESS_CONSUMER_POLLING.

Email import basics

Paperless can also pull attachments from a mailbox. In the UI, go to Mail, add an account, then add a rule for it. I tested this against a throwaway IMAP server on the same Docker network:

  1. Mail account: IMAP server, port, security (None, SSL or STARTTLS), username and password.
  2. Mail rule: folder INBOX, maximum age 30 days, "Only process attachments", and the action "Mark as read".

Paperless-ngx Mail Settings page with one mail account named Lab inbox on server plgreenmail with username scans@lab.test and a Process Mail button, and one enabled mail rule named Scans to Paperless for that account.

I sent a mail with a PDF attachment to the test mailbox and triggered a fetch through the API, which is what the Process Mail button does. The log showed Rule Lab inbox.Scans to Paperless: Consuming attachment stadtwerke-brief.pdf from mail Scan from office printer from scanner@lab.test, and the new document took its title from the mail subject. Without a manual trigger, Paperless checks mail on a schedule. PAPERLESS_EMAIL_TASK_CRON defaults to every ten minutes.

The rule action matters. Per the usage docs, "Mark as read" means Paperless ignores mail that's already read, so a mail you open on your phone first won't be imported. "Move to folder" or a custom tag avoids that. "Delete" removes consumed mail right away, and the docs say to use it with caution.

Backups, upgrades and common errors

Back up and upgrade

The document exporter writes all documents, thumbnails and database contents to a folder. With the compose file above, that's ./export on the host:

docker compose exec -T webserver document_exporter ../export --passphrase 'a-long-passphrase'
Enter fullscreen mode Exit fullscreen mode

Don't skip --passphrase. Without it, the exporter printed No passphrase was given, sensitive fields will be in plaintext, and my test mail account's password was readable in export/manifest.json. With a passphrase, that field was encrypted. Keep the passphrase somewhere other than the backup.

The administration docs add a warning: you can't import an export into a different Paperless version. The other option is to back up the Docker volumes themselves (paperless_media, paperless_data and paperless_pgdata), which you need root to read.

To upgrade, back up first, then change the tag. I tested this from 3.3.0 to 3.3.1 on a separate stack:

docker compose exec -T webserver document_exporter ../export --passphrase 'a-long-passphrase'
docker compose down
# edit docker-compose.yml: paperless-ngx:3.3.0 -> paperless-ngx:3.3.1
docker compose pull
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

docker compose down without -v keeps the volumes. The new container ran the migration step (No migrations to apply. for this patch release), /api/status/ reported version 3.3.1, and the test document was still there and searchable. Read the release notes before every bump. Coming from v2, the v3 migration guide says you must be on 2.20.15 first.

Two v3 changes catch people coming from older guides. PAPERLESS_DBENGINE: postgresql is now required for PostgreSQL, and duplicates are no longer rejected (see the FAQ). Once it runs, an Uptime Kuma HTTP monitor on the login page tells you when it stops answering.

Common errors

The container restarts in a loop after you change OCR settings

Check docker compose logs webserver for The selected ocr language ... is not installed. Add the language to PAPERLESS_OCR_LANGUAGES, or remove it from PAPERLESS_OCR_LANGUAGE. Remember the underscore rule for codes like chi_tra.

CSRF verification failed when you log in through a proxy

The login page loads, but logging in returns 403 CSRF verification failed. Request aborted. To reproduce it without a real proxy, I sent the login request with curl, using the headers a TLS-terminating proxy adds for https://paperless.example.test. The log said:

[WARNING] [django.security.csrf] Forbidden (Origin checking failed - https://paperless.example.test does not match any trusted origins.): /accounts/login/
Enter fullscreen mode Exit fullscreen mode

The fix is PAPERLESS_URL, set to the public address with no trailing slash:

PAPERLESS_URL=https://paperless.example.test
Enter fullscreen mode Exit fullscreen mode

After docker compose up -d, the same request logged in. Per the configuration docs, PAPERLESS_URL fills in Django's allowed hosts, CORS and CSRF trusted origins in one go. If it's set and you still get Origin checking failed - null, a support thread traced that to a Referrer-Policy: no-referrer header added by the proxy.

FAQ

Setup and storage

How much RAM does Paperless-ngx need?

The project doesn't publish a minimum. On my test box, the full stack with PostgreSQL, Valkey, Tika and Gotenberg used about 1.3 GB of RAM after several OCR jobs. Tika and Gotenberg are optional, and leaving them out drops support for Office files. For small machines, the setup docs suggest settings like PAPERLESS_OCR_PAGES=1 and PAPERLESS_OCR_CLEAN=none.

Where are my documents stored with Docker?

In the paperless_media volume, which on Linux is usually under /var/lib/docker/volumes/paperless_media/_data. The FAQ says not to mess with that folder by hand. Files you put in ./consume are copied into the media volume and removed from the consume folder. To get your files out with readable names, use the exporter.

Does Paperless-ngx reject duplicate documents?

Not since v3. When my mail test brought in a PDF I had already imported, the log said Consuming duplicate stadtwerke-brief.pdf: 2 existing document(s) share the same content. and imported it anyway. Per the FAQ, the document page then shows a Duplicates tab that lists the copies. Set PAPERLESS_CONSUMER_DELETE_DUPLICATES=true if you want the old behavior of rejecting them.

OCR and import

Why does Paperless say the selected OCR language is not installed?

PAPERLESS_OCR_LANGUAGE names a language the image doesn't contain. Add it to PAPERLESS_OCR_LANGUAGES so the container installs it at startup, and give the container internet access while it starts. For codes with an underscore, write chi_tra in the singular setting and chi-tra in the plural one.

Why isn't Paperless picking up files from my consume folder?

Check docker compose logs webserver first. A PermissionError means the files belong to a different user than USERMAP_UID, so fix the ownership. If nothing is logged at all and the folder is on NFS or SMB, set PAPERLESS_CONSUMER_POLLING_INTERVAL as the docs describe. Also check that you're writing to the folder that's mounted to /usr/src/paperless/consume in the compose file.

How do I fix "CSRF verification failed" behind a reverse proxy?

Set PAPERLESS_URL to the exact public URL, like https://paperless.example.com with no trailing slash, and recreate the container with docker compose up -d. In my test, that turned a 403 into a successful login.


Originally published on DeployLark: Paperless-ngx Docker Compose Setup with OCR

Top comments (0)