Last updated: November 2026 — confirm image tags on ghcr.io/paperless-ngx before pinning.
For: Someone digitizing household or small-office PDFs/scans who wants OCR, tags, and a consume-folder workflow on a cheap-to-mid VPS, with HTTPS via Caddy.
Do not use this guide when:
You only need a shared folder of PDFs with no OCR/metadata (use object storage + search elsewhere)
The consume directory must be written by a host UID that you refuse to map (USERMAP_UID / GID)
You expect mobile “scan to Paperless” magic without a scanner path or companion app
You put media on a flaky network share and expect OCR workers to never corrupt jobs
Paperless is lighter than Immich; a 2 GB VPS can work for dozens of docs/week if you avoid Office/Tika until you need it.
webserver — ghcr.io/paperless-ngx/paperless-ngx
broker — Valkey/Redis
caddy — public HTTPS → webserver:8000
Bind mounts: consume/ (drop zone), export/, plus Docker volumes for data/media/db
~/paperless/
docker-compose.yml
docker-compose.env
Caddyfile
.env # COMPOSE project name optional
consume/
export/
caddy-data/
caddy-config/
# Host user that writes into ./consume (id -u / id -g)
USERMAP_UID=1000
USERMAP_GID=1000
PAPERLESS_URL=https://docs.example.com
PAPERLESS_SECRET_KEY=replace-with-output-of-openssl-rand-hex-32
PAPERLESS_TIME_ZONE=Asia/Shanghai
PAPERLESS_OCR_LANGUAGE=eng
# Extra tessdata packs (space-separated), if needed:
# PAPERLESS_OCR_LANGUAGES=chi_sim
PAPERLESS_ADMIN_USER=admin
PAPERLESS_ADMIN_PASSWORD=change-me-strong
PAPERLESS_ADMIN_MAIL=you@example.com
Generate secret:
openssl rand -hex 32
docs.example.com {
encode gzip
reverse_proxy webserver:8000
}
services:
broker:
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
db:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless-db-change-me
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless"]
interval: 10s
timeout: 5s
retries: 5
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
broker:
condition: service_started
volumes:
- data:/usr/src/paperless/data
- media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: paperless-db-change-me
env_file:
- docker-compose.env
# No public ports — Caddy only
expose:
- "8000"
caddy:
image: caddy:2.11.7
restart: unless-stopped
depends_on:
- webserver
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
volumes:
data:
media:
pgdata:
redisdata:
Match PAPERLESS_DBPASS with POSTGRES_PASSWORD. Pin paperless-ngx to a release tag (e.g. 2.x.y) once you confirm a good version on ghcr.io.
If PAPERLESS_URL ≠ the URL in the browser (http vs https, wrong host), expect login/CSRF failures.
mkdir -p consume export && chmod 775 consume (adjust for your UID)
Drop a PDF into ./consume
Watch: docker compose logs -f webserver
Document appears in the UI after OCR; failed files often land with messages in logs
For Chinese scans, set PAPERLESS_OCR_LANGUAGE / PAPERLESS_OCR_LANGUAGES appropriately and recreate the container
Permissions footgun: If the scanner/NAS writes as UID 1001 but USERMAP_UID=1000, consume silently stalls. Align UIDs or chown the drop directory.
Set PAPERLESS_URL=https://docs.example.com to match the Caddy site name exactly (scheme + host).
Only Caddy publishes 80/443.
After DNS propagates: docker compose up -d and confirm cert issuance in docker compose logs caddy.
Backup
Export from UI or:
docker compose exec webserver document_exporter /usr/src/paperless/export
Copy ./export off-box (this is the portable document set)
Additionally dump Postgres:
docker compose exec -T db pg_dump -U paperless paperless > paperless-db-$(date +%F).sql
Save docker-compose.env (secrets) separately
Optional: docker compose down then archive named volumes with a volume-backup tool if you do not trust exporter-only restores
Restore
Bring up empty stack
Restore DB dump or use document_importer against an export tree (follow current Paperless docs for importer flags)
Confirm consume permissions still match USERMAP_*
Log in; spot-check tags and full-text search on an old invoice
[ ] https://docs.example.com serves a valid certificate and login page
[ ] Admin login works with PAPERLESS_ADMIN_*
[ ] Drop PDF into consume/ → appears in UI with searchable text
[ ] Tag + save; search finds a unique string inside the PDF
[ ] document_exporter writes files under ./export
[ ] DB dump restores on a second test stack (or importer succeeds once)
Tika / Gotenberg for Office docs (more RAM)
Samba/WebDAV drop into consume/
Fail2ban / SSO in front of Caddy
Default images: ghcr.io/paperless-ngx/paperless-ngx:latest, postgres:16-alpine, valkey:9-alpine, caddy:2.11.7. Pin Paperless to a release tag for production.