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 to127.0.0.1:8000, and put a randomPAPERLESS_SECRET_KEYand database password in a.envfile. - Open the web UI right away. The first account you sign up with becomes the superuser.
-
PAPERLESS_OCR_LANGUAGEpicks the OCR languages (deu+eng).PAPERLESS_OCR_LANGUAGESinstalls extra ones (pol). English, German, French, Italian and Spanish are already in the image. - Drop files into
./consumeor add an IMAP account and a mail rule. Back up withdocument_exporter --passphrase, and upgrade by changing the tag and runningdocker 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:
-
webserveris 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. -
dbis PostgreSQL. The setup docs recommend it for new installs. SQLite and MariaDB also work. -
brokeris Valkey, a Redis-compatible store that queues background tasks. -
tikaandgotenbergare optional. They let Paperless read Office files such as.docx,.xlsxand.odt, plus.emlemail 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
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:
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
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))"
First start and the superuser
Start the stack
docker compose pull
docker compose up -d
docker compose logs -f webserver
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
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:
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 asdeu+eng. It defaults toeng. -
PAPERLESS_OCR_LANGUAGES(plural) is Docker only. It installs extra language packs when the container starts, as a space-separated list such aspol 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:
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.
Adding the install line fixed it:
PAPERLESS_OCR_LANGUAGE=pol+eng
PAPERLESS_OCR_LANGUAGES=pol
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_MODEdefaults toauto. Paperless checks whether a PDF already has text and skips OCR if it does.redoreplaces existing text layers, which helps when your scanner's own OCR is poor.forcerasterizes everything first.offnever runs OCR. -
PAPERLESS_ARCHIVE_FILE_GENERATIONdecides when a PDF/A copy is made. The defaultautomakes one for scans and image PDFs and skips born-digital PDFs. -
PAPERLESS_OCR_PAGES=1OCRs only the first page, which the docs suggest for slow hardware. -
PAPERLESS_OCR_CLEANruns unpaper before OCR. The default isclean. The setup docs saynonespeeds 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
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:
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'
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:
- Mail account: IMAP server, port, security (None, SSL or STARTTLS), username and password.
- Mail rule: folder
INBOX, maximum age 30 days, "Only process attachments", and the action "Mark as read".
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'
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
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/
The fix is PAPERLESS_URL, set to the public address with no trailing slash:
PAPERLESS_URL=https://paperless.example.test
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)