Papermerge 3.5.3 Docker Compose 部署指南

Papermerge 3.5.3 六服务 Docker Compose 全栈部署实录:登录/上传/OCR/全文检索闭环、持久化布局、环境变量表与七个排障实录

PapermergePapermerge文档管理2026/11/02

Papermerge 3.5.3 Docker Compose 部署指南

目标:在一台 Linux 服务器上,用一份 compose 文件把 Papermerge 3.5.3 的完整生产形态(webapp + PostgreSQL + Redis + Solr + OCR worker + i3 worker 六服务)部署起来,并以实测闭环(登录 → 建目录 → 上传扫描件 → OCR → 全文检索命中)验证部署成功。

组件定位与选型见站内《Papermerge 介绍与选型指南》;参数逐项详解见本专栏《Papermerge 3.5.3 配置参数详解》;REST API 用法见本专栏《Papermerge 3.5.3 API 使用详解》。本文全部命令与回显均为 2026-11-01/02 实测照抄。

一、环境信息

项目值
操作系统Linux(x86_64,内核 7.0.0-38-generic)
部署工具Docker Compose(compose spec 支持 depends_on.condition)
应用版本Papermerge 3.5.3(官方当前最新稳定版)
对外端口8138(映射到 webapp 的 80)
内部网段10.89.57.0/24(compose 项目专用子网)
登录账号admin / q1w2e3r4(示例值,部署时请替换)
组件全栈papermerge/papermerge:3.5.3、papermerge/ocrworker:0.3.1、papermerge/i3worker:0.3、postgres:16.1、redis:7.2、solr:9.7

组件版本与官方 3.5.3 版本矩阵一致(papermerge-core 官方文档 admin-manual/version-matrix,查询日期 2026-11-01)。auth-server 无需单独部署:3.5.3 的 webapp 镜像内部已自带基础认证服务并随 supervisord 一起拉起,实测 webapp 启动日志可见 nginx + uvicorn(:8000) + auth-server(:4010) 同进程组共存(见 4.5 节)。

二、部署架构与组件选型

官方设计哲学是"渐进式部署":从最小 webapp 起步,按需逐个加 PostgreSQL、media 持久化、Redis、OCR worker、Solr + i3 worker、S3/path-tmpl worker(来源:官方文档 setup/docker-compose,查询日期 2026-11-01)。本文一步到位部署官方文档 I3 + OCR Workers 终态组合,即生产推荐的完整形态:

浏览器 ──▶ :8138 ──▶ webapp(nginx:80 + Django:8000 + 内置 auth-server:4010)
                       │        │            │
              元数据    │   任务队列(Redis)    │  索引读写
                       ▼        ▼            ▼
                     db      redis          solr(core: pmg)
                  (postgres:5432) (:6379)   (:8983)
                       ▲        ▲            ▲
                       │  ┌─────┴─────┐      │
                       └──┤ ocr_worker │      │(celery 队列 ocr)
                          ├────────────┤      │
                          │  i3worker  │──────┘(celery 队列 i3,DB→Solr 同步)
                          └────────────┘
        webapp 与 ocr_worker 共享 bind mount:./data/media → /var/media/pmg

选型要点(均为官方版本矩阵与本文实测结论):

  1. 数据库:webapp 默认回落 SQLite(sqlite:////db/db.sqlite3),生产必须显式设置 PAPERMERGE__DATABASE__URL 指向 PostgreSQL;三个应用组件(webapp/ocr_worker/i3worker)必须使用同一个数据库 URL。
  2. 检索:默认本地 Xapian;本例用 Solr(solr://solr:8983/pmg),由 i3worker 负责把数据库内容同步进索引。Solr core 的 schema 必须预置补丁,否则全文检索恒 0 命中(见第九章问题 4,这是本次部署最大的坑)。
  3. 队列:Redis 作为 Celery broker,队列名固定 ocr / i3(worker 启动横幅实测 [queues] .> ocr exchange=ocr(direct) key=ocr)。
  4. media 共享:上传的原始文件与 OCR 产物都落在文件系统 media root,不进数据库;webapp 与 ocr_worker 必须挂载同一份 media 目录。

三、前置条件

  1. Docker 与 Docker Compose 已安装(docker compose version 可用)。
  2. 宿主机端口 8138 空闲;若与其他自建栈共用 Docker 网络资源,建议像本文一样为项目显式指定子网,避免网段漂移。
  3. 磁盘:本例部署闭环跑完(3 个文档、OCR 若干次)后的持久化目录占用实测为:
2.0M	data/media
48M	data/pgdata
1.9M	data/solr
  1. 准备两个验证文件(可任意):一个纯图像无文本层的扫描 PDF(测 OCR),一个自带文本层的 PDF(测索引)。本例用自制的 invoice-scan.pdf(81232 字节,德文发票扫描件,含关键词 KAFFEEMUEHLE)与 notes-text.pdf(631 字节,文本层含标记 778811)。

四、部署步骤

4.1 准备 Solr core 配置补丁(必做,先于首次启动)

官方 compose 示例用 solr-precreate pmg(默认 configset)建 core,但默认 schema 没有到 _text_ 的 copyField,而 Papermerge 的检索组件是按"裸词 + df=text"查询的——照官方示例部署,OCR 再成功全文检索也永远 0 命中(诊断过程见第九章问题 4)。正确做法是准备一份打过补丁的 configset 目录,首启即生效。

本项目目录结构:

stack/
├── docker-compose.yml
├── solr-config/          # 打过补丁的 configset(挂载进 solr 供 solr-precreate 使用)
│   ├── managed-schema.xml
│   ├── solrconfig.xml
│   ├── lang/ ...
└── data/                 # bind mount 持久化:media / pgdata / solr

solr-config/managed-schema.xml 相对默认 configset 的两处关键改动(实测该文件生效内容):

<field name="page_number" type="pint" multiValued="false" indexed="true" stored="true"/>
...
<copyField source="*_txt_en" dest="_text_"/>
  • copyField *_txt_en → _text_:让动态规则命中的 title_txt_en/text_txt_en 汇入 _text_,裸词查询才能命中(问题 4 的修复);
  • page_number 定义为单值 pint:Papermerge 的索引组件 salinic 以单值 NumericField 写入该字段,若沿用默认多值定义,旧段与新段 docValues 类型冲突会导致文档写入被拒(问题 6 的修复)。

补丁做法二选一:像本文一样在首启前备好 configset 目录;或先用默认 configset 起,再走 Schema API 修改 + 清段 + 重建索引(步骤繁琐且要 force-recreate,见问题 6)。强烈建议前者。

4.2 编写 compose 文件

stack/docker-compose.yml 全文(即本次实测使用的文件,仅 SECRET_KEY 与密码为示例值):

name: papermerge-353-stack

services:
  webapp:
    image: papermerge/papermerge:3.5.3
    environment:
      PAPERMERGE__SECURITY__SECRET_KEY: "kX9vT2mQm4pR7wLz0cJdNfHbYs6uG1eA3iO5rT8yW0qZ4xC7vB9nM2kJ5pR8tY1u"
      PAPERMERGE__AUTH__USERNAME: admin
      PAPERMERGE__AUTH__PASSWORD: q1w2e3r4
      PAPERMERGE__AUTH__EMAIL: admin@example.com
      PAPERMERGE__DATABASE__URL: postgresql://pmuser:q1w2e3r4@db:5432/pmgdb
      PAPERMERGE__MAIN__MEDIA_ROOT: /var/media/pmg
      PAPERMERGE__REDIS__URL: redis://redis:6379/0
      PAPERMERGE__SEARCH__URL: solr://solr:8983/pmg
      PAPERMERGE__OCR__LANG_CODES: "deu,eng,ron"
      PAPERMERGE__OCR__DEFAULT_LANG_CODE: "deu"
    volumes:
      - ./data/media:/var/media/pmg
    ports:
      - "8138:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      solr:
        condition: service_started

  ocr_worker:
    image: papermerge/ocrworker:0.3.1
    command: worker
    environment:
      PAPERMERGE__DATABASE__URL: postgresql://pmuser:q1w2e3r4@db:5432/pmgdb
      PAPERMERGE__REDIS__URL: redis://redis:6379/0
      PAPERMERGE__MAIN__MEDIA_ROOT: /var/media/pmg
      OCR_WORKER_ARGS: "-Q ocr -c 2"
    volumes:
      - ./data/media:/var/media/pmg
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  i3worker:
    image: papermerge/i3worker:0.3
    command: worker
    environment:
      PAPERMERGE__DATABASE__URL: postgresql://pmuser:q1w2e3r4@db:5432/pmgdb
      PAPERMERGE__SEARCH__URL: solr://solr:8983/pmg
      PAPERMERGE__REDIS__URL: redis://redis:6379/0
      I3_WORKER_ARGS: "-Q i3 -c 2"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      solr:
        condition: service_started

  db:
    image: postgres:16.1
    volumes:
      - ./data/pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: q1w2e3r4
      POSTGRES_DB: pmgdb
      POSTGRES_USER: pmuser
    healthcheck:
      test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
      interval: 5s
      timeout: 10s
      retries: 5
      start_period: 10s

  redis:
    image: redis:7.2

  solr:
    image: solr:9.7
    volumes:
      - ./data/solr:/var/solr
      - ./solr-config:/opt/pmgschema:ro
    command:
      - solr-precreate
      - pmg
      - /opt/pmgschema

networks:
  default:
    ipam:
      config:
        - subnet: 10.89.57.0/24

与官方示例的差异说明:

  1. solr 服务多挂了一个只读目录并把 configset 作为 solr-precreate 第三参数传入——即 4.1 的补丁,官方示例没有这一步(坑源)。
  2. 持久化一律用 bind mount(./data/...)而非命名卷,便于直接盘点与备份;生产也可换命名卷。
  3. redis/solr 未对外发布端口,仅内部网络可达;Solr 直查需走内部地址(见 4.7 与第九章)。
  4. PAPERMERGE__OCR__LANG_CODES 按官方文档配置了 UI 语言下拉;实测该键在 3.5.3 无任何消费代码,属"写了不报错但不起作用"的配置(详见《Papermerge 3.5.3 配置参数详解》OCR 节与第九章问题 3 的白名单实测)。

4.3 拉取镜像

cd stack
docker compose pull

6 个镜像全部拉取成功(拉取日志尾行示例):

 Image solr:9.7 Pulled

4.4 启动

docker compose up -d

首启时序(实测各服务日志):

  • db 先初始化 PostgreSQL 集群并进入 healthy:
db-1  | The files belonging to this database system will be owned by user "postgres".
db-1  | This user must also own the server process.
db-1  | The database cluster will be initialized with locale "en_US.utf8".
db-1  | fixing permissions on existing directory /var/lib/postgresql/data ... ok
db-1  | creating subdirectories ... ok
db-1  | running bootstrap script ... ok
db-1  | performing post-bootstrap initialization ... ok
db-1  | syncing data to disk ... ok
db-1  | Success. You can now start the database server using:
db-1  |     pg_ctl -D /var/lib/postgresql/data -l logfile start
db-1  | waiting for server to start....2026-11-01 16:07:01.985 UTC [48] LOG:  starting PostgreSQL 16.1 (Debian 16.1-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
  • solr 用挂载的 configset 预建 core:
solr-1  | Executing /opt/solr/docker/scripts/solr-precreate pmg
solr-1  | Executing /opt/solr/docker/scripts/precreate-core pmg
solr-1  | Created pmg
solr-1  | Starting Solr
solr-1  | 2026-11-01 16:12:29.711 INFO  (main) [c: s: r: x: t:] o.a.s.s.CoreContainerProvider / __| ___| |_ _   Starting in standalone mode on port 8983
solr-1  | 2026-11-01 16:12:29.712 INFO  (main) [c: s: r: x: t:] o.a.s.s.CoreContainerProvider |___/\___/_|_|    Start time: 2026-11-01T16:12:29.712236837Z
  • webapp 自动跑数据库迁移、自动创建超级用户(无需手工初始化):
webapp-1  | INFO  [alembic.runtime.migration] Running upgrade  -> bc29f69daca4, Initial migration
webapp-1  | INFO  [alembic.runtime.migration] Running upgrade 4756d6d67b0d -> ad7145f8e1d5, v3.5.3: 0002 - Set home_folder_id and inbox_folder_id to NOT NULL
webapp-1  | INFO  [alembic.runtime.migration] Running upgrade ad7145f8e1d5 -> 999b99441bd6, v3.5.3: 0003 - Set node_user_id foreign key constraint as deferrable
webapp-1  | INFO  [alembic.runtime.migration] Running upgrade 999b99441bd6 -> 50ef5a403d27, v3.5.3: 0004 -Add non-empty constraint to role name
webapp-1  | User admin created
  • ocr_worker 以 Celery 形态监听 ocr 队列(root 运行的 SecurityWarning 为官方镜像固有行为,可忽略):
ocr_worker-1  |  -------------- celery@6925c7f736f0 v5.4.0 (opalescent)
ocr_worker-1  | --- ***** ----- 
ocr_worker-1  | -- ******* ---- Linux-7.0.0-38-generic-x86_64-with-glibc2.36 2026-11-01 16:07:09
ocr_worker-1  | - ** ---------- .> app:         ocrworker:0x71556e7a81a0
ocr_worker-1  | - ** ---------- .> transport:   redis://redis:6379/0
ocr_worker-1  | - ** ---------- .> results:     redis://redis:6379/0
ocr_worker-1  | - *** --- * --- .> concurrency: 2 (prefork)
ocr_worker-1  |  -------------- [queues]
ocr_worker-1  |                 .> ocr              exchange=ocr(direct) key=ocr

注意:首启日志里 webapp 的 exec_index_schema_apply 步骤可能因 Solr 尚未就绪而打印一段连接异常 traceback 后继续启动(实测如此)。只要 4.1 的 configset 补丁到位、i3worker 正常,这个失败不影响最终检索;若首启后检索不通,优先按第九章问题 7 处理,不要去 webapp 里补跑 paper-cli index-schema apply。

4.5 验证服务状态

docker compose ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'

实测回显(六服务全部 Up):

SERVICE      IMAGE                         STATUS                  PORTS
db           postgres:16.1                 Up 12 hours (healthy)   5432/tcp
i3worker     papermerge/i3worker:0.3       Up 12 hours
ocr_worker   papermerge/ocrworker:0.3.1    Up 12 hours
redis        redis:7.2                     Up 12 hours             6379/tcp
solr         solr:9.7                      Up 3 hours              8983/tcp
webapp       papermerge/papermerge:3.5.3   Up 12 hours             0.0.0.0:8138->80/tcp, [::]:8138->80/tcp

Web 入口与接口探活:

curl -sI http://127.0.0.1:8138/
curl -s http://127.0.0.1:8138/ | grep -oE '<title>[^<]*</title>'
curl -s http://127.0.0.1:8138/api/version/ -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w 'HTTP=%{http_code} bytes=%{size_download}\n' \
     http://127.0.0.1:8138/api/probe/ -H "Authorization: Bearer $TOKEN"

实测回显:

HTTP/1.1 200 OK
Server: nginx/1.28.0
Date: Mon, 02 Nov 2026 03:40:53 GMT
Content-Type: text/html
Content-Length: 492
Last-Modified: Mon, 11 Aug 2025 06:14:36 GMT
Connection: keep-alive
ETag: "68998a4c-1ec"
Accept-Ranges: bytes
<title>Papermerge DMS</title>
{"version":"3.5.3"}
HTTP=200 bytes=0

(/api/probe/ 返回 200 空 body,是官方健康检查端点;/api/version/ 回显 {"version":"3.5.3"}。)

4.6 登录验证

Papermerge 3.x 的登录走内置 auth-server 的 POST /api/token,必须 JSON 编码(form 编码实测 422):

curl -s -X POST http://127.0.0.1:8138/api/token \
     -H 'Content-Type: application/json' \
     -d '{"username":"admin","password":"q1w2e3r4"}'

实测回显(access_token 截前 260 字符,全文 729 字符):

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMGRiYTI5NC1hOTRmLTQ0NzktODhkZS1jOWRmMGFjNGMxN2YiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJzY29wZXMiOlsibm9kZS5jcmVhdGUiLCJub2RlLnZpZXciLCJub2RlLnVwZGF0ZSIsIm
TOKEN_LEN=729

响应同时通过 set-cookie: access_token=... 下发会话 Cookie(浏览器 UI 登录即此通道),JWT 的 sub 为用户 UUID。拿 token 后验证身份端点:

curl -s http://127.0.0.1:8138/api/users/me -H "Authorization: Bearer $TOKEN"

实测回显(原文照抄,scopes 数组为超级用户全量权限清单):

{"id":"a0dba294-a94f-4479-88de-c9df0ac4c17f","username":"admin","email":"admin@example.com","created_at":"2026-11-01T16:07:15.292229","updated_at":"2026-11-01T16:07:15.292229","home_folder_id":"6a1bdab1-a5ca-4475-be7e-627631ba8a02","inbox_folder_id":"a6e59095-e270-4f9a-9493-92f8a723d291","is_superuser":true,"is_active":true,"scopes":["custom_field.create","custom_field.delete","custom_field.update","custom_field.view","document.download","document.download","document.download.all_versions","document.download.last_version_only","document.update.custom_fields","document.update.document_type","document.update.tags","document.update.title","document.upload","document.upload","document_type.create","document_type.delete","document_type.select","document_type.update","document_type.view","group.create","group.create","group.delete","group.delete","group.select","group.update","group.update","group.view","group.view","node.create","node.create","node.delete","node.delete","node.move","node.move","node.update","node.update","node.view","node.view","ocrlang.view","ocrlang.view","page.delete","page.extract","page.extract","page.move","page.move","page.reorder","page.rotate","page.update","page.update","page.view","page.view","role.create","role.delete","role.select","role.update","role.view","shared_node.create","shared_node.delete","shared_node.update","shared_node.view","tag.create","tag.create","tag.delete","tag.delete","tag.select","tag.update","tag.update","tag.view","tag.view","task.ocr","task.ocr","user.create","user.create","user.delete","user.delete","user.me","user.me","user.select","user.update","user.update","user.view","user.view"]}

不带 token 访问受保护端点实测返回 HTTP=401,符合预期。UI 侧用浏览器打开 http://<服务器>:8138,以 admin / q1w2e3r4 登录即可。

4.7 上传、OCR 与全文检索闭环验证

以下序列全部走 REST API(与 UI 同一后端),命令与回显为实测照抄。

1) 建目录 finance(父节点取 users/me 的 inbox_folder_id):

curl -s -X POST http://127.0.0.1:8138/api/nodes/ \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"finance","ctype":"folder","parent_id":"a6e59095-e270-4f9a-9493-92f8a723d291"}'

实测回显(记录新节点 id 到变量 FID):

FID=48f098ee-8f86-4a24-ae84-bd11d4efdb8c

2) 建文档节点 pm-invoice(lang=deu、ocr=false 表示稍后手动调度 OCR):

curl -s -X POST http://127.0.0.1:8138/api/nodes/ \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"pm-invoice","ctype":"document","lang":"deu","ocr":false,"parent_id":"48f098ee-8f86-4a24-ae84-bd11d4efdb8c"}'

实测回显:

DOCID3=a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd

3) 上传扫描件(multipart):

curl -s -X POST http://127.0.0.1:8138/api/documents/$DOCID3/upload \
  -H "Authorization: Bearer $TOKEN" -F "file=@invoice-scan.pdf"

实测回显(从响应 JSON 的 versions[0] 提取关键字段):

upload ok: size=81232 page_count=1 version_id=7371b12f-0f45-48e5-9e9b-2da9fa490d68

上传响应体是完整文档对象(含 versions/pages/download_url 等),原始全文示例见《Papermerge 3.5.3 API 使用详解》上传节。

4) 调度 OCR(deu):

curl -s -X POST http://127.0.0.1:8138/api/tasks/ocr \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"document_id":"a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd","lang":"deu"}'

实测回显:null(HTTP 200,任务已投递到 ocr 队列;受理即返回,异步执行)。

5) 等待 1-2 秒后验证 OCR 产物。 任务执行不打印 celery 日志,验证看两处:Redis 中的 celery-task-meta-*(实测全部 SUCCESS,单页 PDF 从投递到文本入库约 1-2s)与 media 盘上的产物:

find data/media/ocr -name page.txt | head -4
F=$(find data/media/ocr -name page.txt | head -1); echo "--- $F ---"; cat "$F"
data/media/ocr/pages/3a/65/3a6508af-1199-4100-8154-3f9ad27b7f23/page.txt
data/media/ocr/pages/9e/70/9e70675f-4504-4c8b-84ff-f78bc87bd08d/page.txt
data/media/ocr/pages/7a/30/7a30ce83-ae00-4bc6-b638-27387140c421/page.txt
data/media/ocr/pages/88/a6/88a68182-b667-4174-ac68-2e49c0f5847e/page.txt
--- data/media/ocr/pages/3a/65/3a6508af-1199-4100-8154-3f9ad27b7f23/page.txt ---
PAPERMERGE TEXTLAYER MARKER 778811 qual

OCR 完成后文档自动产生第二个版本(实测 GET /api/documents/$DOCID3/versions):

[{"id":"665be4ac-5fd7-48aa-80c8-94d390b36e89","number":2,"short_description":"With OCR text layer"},{"id":"7371b12f-0f45-48e5-9e9b-2da9fa490d68","number":1,"short_description":"Original"}]

6) 全文检索闭环(最终态实测)。 对扫描关键词与文本层关键词各搜一次:

curl -s "http://127.0.0.1:8138/api/search/?q=KAFFEEMUEHLE" -H "Authorization: Bearer $TOKEN"
curl -s "http://127.0.0.1:8138/api/search/?q=778811" -H "Authorization: Bearer $TOKEN"
{"page_size":10,"page_number":1,"num_pages":1,"items":[{"id":"88a68182-b667-4174-ac68-2e49c0f5847e","title":"invoice-scan","lang":"en","tags":[],"page_number":1,"document_id":"125122b8-baca-4533-a47b-e64bafa48204","entity_type":"document"},{"id":"9e70675f-4504-4c8b-84ff-f78bc87bd08d","title":"invoice-fresh","lang":"en","tags":[],"page_number":1,"document_id":"2a4692ef-d728-4c2d-924d-d8e23b91d305","entity_type":"document"},{"id":"7a30ce83-ae00-4bc6-b638-27387140c421","title":"pm-invoice","lang":"en","tags":[],"page_number":1,"document_id":"a760b7a0-8e08-4023-9f5a-7b2f7f6eeccd","entity_type":"document"}]}
{"page_size":10,"page_number":1,"num_pages":1,"items":[{"id":"3a6508af-1199-4100-8154-3f9ad27b7f23","title":"notes-text","lang":"en","tags":[],"page_number":1,"document_id":"7b3e8499-fcdc-4e33-bd81-84d2a4ed3843","entity_type":"document"},{"id":"84585308-cead-4240-846b-fa97c800148b","title":"pm-notes","lang":"en","tags":[],"page_number":1,"document_id":"b55c1248-fb18-4f75-b41f-befbbc9e6243","entity_type":"document"}]}

KAFFEEMUEHLE(纯扫描件 OCR 出的词)命中 3 条、778811(PDF 自带文本层)命中 2 条——登录 → 上传 → OCR → 索引 → 检索 全链路闭环成立。

7) 下载带 OCR 文本层的最新版本:

VID=$(curl -s http://127.0.0.1:8138/api/documents/$DOCID3/last-version/ \
      -H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")
curl -s http://127.0.0.1:8138/api/document-versions/$VID/download \
     -H "Authorization: Bearer $TOKEN" -o /tmp/pm-dl.pdf
echo "downloaded version=$VID bytes=$(stat -c %s /tmp/pm-dl.pdf) sha256=$(sha256sum /tmp/pm-dl.pdf | cut -c1-16)"

实测回显:

downloaded version=665be4ac-5fd7-48aa-80c8-94d390b36e89 bytes=50126 sha256=b400cab5c5160e3f

(OCR 版比原始 81232 字节小,因文本层替换了原图像编码——README 宣称的"OCR 文本叠加层可下载"实测成立。)

五、数据持久化说明

三个 bind mount 目录即全部状态,备份它们等于备份整个 Papermerge:

宿主目录挂载点内容实测用量(闭环跑完)
data/pgdatadb:/var/lib/postgresql/data元数据库(节点/文档/用户/标签/自定义字段)48M
data/mediawebapp 与 ocr_worker:/var/media/pmg上传原件 + OCR 产物2.0M
data/solrsolr:/var/solrSolr 索引(core: pmg,含 conf 与段数据)1.9M

media root 的盘上布局按 UUID 前 4 个 hex 字符两级分片:原始文件在 docvers/xx/yy/<文档版本UUID>/<原文件名>,OCR 逐页产物在 ocr/pages/xx/yy/<页UUID>/page.pdf + page.txt。find data/media -maxdepth 4 | head -20 实测回显:

data/media
data/media/ocr
data/media/ocr/pages
data/media/ocr/pages/3a
data/media/ocr/pages/3a/65
data/media/ocr/pages/9e
data/media/ocr/pages/9e/70
data/media/ocr/pages/7a
data/media/ocr/pages/7a/30
data/media/ocr/pages/88
data/media/ocr/pages/88/a6
data/media/ocr/pages/84
data/media/ocr/pages/84/58
data/media/ocr/pages/0c
data/media/ocr/pages/0c/85
data/media/docvers
data/media/docvers/00
data/media/docvers/00/08
data/media/docvers/00/08/00088034-7d72-4b34-b8bb-a5829115c297
data/media/docvers/5a

find data/media/docvers -type f | head -5 实测回显:

data/media/docvers/00/08/00088034-7d72-4b34-b8bb-a5829115c297/notes-text.pdf
data/media/docvers/5a/e8/5ae876fd-98b9-4041-9759-aa967be2bc83/invoice-scan.pdf
data/media/docvers/af/7d/af7d6852-b230-4d9a-80cd-81f24c8b5f63/notes-text.pdf
data/media/docvers/73/71/7371b12f-0f45-48e5-9e9b-2da9fa490d68/invoice-scan.pdf
data/media/docvers/73/93/7393775a-9768-4513-844b-7f380773d14b/notes-text.pdf

要点:上传文件不进数据库(官方文档明示,media root 独立持久化是官方 compose 教程专门一章的原因);data/solr 可以整目录删除——索引是数据库+media 的派生物,删掉后重建即可(见 8.3)。

六、环境变量表

compose 中实际使用的变量(默认值与更多参数详解见《Papermerge 3.5.3 配置参数详解》):

变量本例取值说明
PAPERMERGE__SECURITY__SECRET_KEY64 位随机串必填,JWT 签名密钥,泄露即可伪造登录态
PAPERMERGE__AUTH__USERNAMEadmin超级用户名,默认 admin
PAPERMERGE__AUTH__PASSWORDq1w2e3r4必填,超级用户密码,首启自动建号
PAPERMERGE__AUTH__EMAILadmin@example.com超级用户邮箱
PAPERMERGE__DATABASE__URLpostgresql://pmuser:...@db:5432/pmgdb默认 SQLite;三组件必须一致
PAPERMERGE__REDIS__URLredis://redis:6379/0Celery broker;webapp 与 worker 必须一致
PAPERMERGE__SEARCH__URLsolr://solr:8983/pmg默认 xapian:////index_db;webapp 与 i3worker 必须一致
PAPERMERGE__MAIN__MEDIA_ROOT/var/media/pmg默认 media;webapp 与 ocr_worker 共享
PAPERMERGE__OCR__DEFAULT_LANG_CODEdeuOCR 默认语言(仅 AUTOMATIC=false 时参与 UI)
PAPERMERGE__OCR__LANG_CODESdeu,eng,ron官方定位 UI 下拉展示;实测 3.5.3 无消费代码
OCR_WORKER_ARGS-Q ocr -c 2ocrworker 的 celery 参数(队列名固定 ocr)
I3_WORKER_ARGS-Q i3 -c 2i3worker 的 celery 参数(队列名固定 i3)
POSTGRES_USER/PASSWORD/DBpmuser/q1w2e3r4/pmgdbdb 服务初始化参数

命名约定:PAPERMERGE__<SECTION>__<KEY>,双下划线分节、全大写;每个组件(webapp/auth-server/ocrworker/i3worker)各自有一套 pydantic-settings 定义,同名键需在各服务间保持同步。

七、等价 docker run(最小单服务形态)

官方文档给出的快速演示形态是单服务 docker run(来源:docs.papermerge.io setup/docker,查询日期 2026-11-01,非本文实测):

docker run -p 12000:80 \
    -e PAPERMERGE__SECURITY__SECRET_KEY=abc \
    -e PAPERMERGE__AUTH__PASSWORD=pass123 \
    papermerge/papermerge:3.5.3

访问 http://localhost:12000,凭据 admin / pass123。与本文 compose 全栈的差异:数据库落容器内 SQLite、检索为本地 Xapian、无 OCR/索引 worker,官方明确此形态仅作快速演示。用它先验证"能登录、能上传",再升级到本文六服务形态,是官方推荐的渐进路线。Apple 芯片需先 docker pull --platform linux/amd64 papermerge/papermerge:3.5.3。

八、常用运维命令

8.1 启停与状态

docker compose up -d          # 启动/更新后拉起
docker compose down           # 停止并移除服务(数据在 bind mount,不丢)
docker compose ps             # 状态一览(见 4.5)
docker compose logs -f webapp # 追日志;worker 同理

8.2 健康检查与版本

curl -s http://127.0.0.1:8138/api/probe/ -H "Authorization: Bearer $TOKEN"   # 200 即健康
curl -s http://127.0.0.1:8138/api/version/ -H "Authorization: Bearer $TOKEN"  # {"version":"3.5.3"}

8.3 重建检索索引

Solr 里数据异常(如 schema 变更后、误删 core 内容后)的标准恢复流程,在 i3worker 内执行:

docker exec papermerge-353-stack-i3worker-1 sh -c 'cd /app && poetry run i3 apply'
docker exec papermerge-353-stack-i3worker-1 sh -c 'cd /app && poetry run i3 index --rebuild'

i3 apply 按 i3worker 自己的字段定义恢复 schema(含 copyField),--rebuild 全量重灌。实测重建后 Solr count=8,API 检索恢复命中(见 4.7)。切勿改用 webapp 侧的 paper-cli index-schema apply,两者模型不一致会毁掉索引写入(问题 7)。

8.4 Solr 直查(排障用)

Solr 未对外发布端口,从内部查:

docker exec papermerge-353-stack-solr-1 \
  curl -s "http://127.0.0.1:8983/solr/pmg/select?q=KAFFEEMUEHLE&wt=json"

实测回显(修复后):numFound= 3,命中 doc id 与 API 搜索一致。

8.5 任务队列观测

ocrworker/i3worker 默认不打印 celery「Task received」日志(worker 日志只见启动横幅),验证任务是否真正执行要看 Redis 里的任务元数据键 celery-task-meta-*(redis-cli 连到 redis 服务的 6379 端口即可 KEYS/GET 查看)与 media 盘上产物。实测:闭环期间 Redis 中 18 个 celery-task-meta-* 键状态全 SUCCESS,date_done 与上传/OCR 操作时刻吻合(如 00:16:07-08 与 00:22:07-08 两批);单页 PDF 从投递到文本入库约 1-2 秒。

九、常见问题(排障实录)

以下 7 个问题全部来自本次部署实测踩坑,按暴露顺序记录。

问题 1:REST 路径带 /v1 一律 404

现象:按直觉写 POST /api/v1/tasks/ocr,webapp 日志 "POST /v1/tasks/ocr HTTP/1.0" 404,响应 {"detail":"Not Found"}。

根因:nginx 把 /api/(.*) 反代到 :8000/(.*),OpenAPI 实际路径没有 /v1 段(/api/token、/api/nodes/、/api/documents/{id}/upload、/api/tasks/ocr、/api/search/、/api/users/me、/api/version/、/api/probe/)。

处理:一律用 /api/*;端点清单以实例匿名可读的 GET /openapi.json(title "Papermerge DMS REST API" version 3.5.3)为准。

问题 2:登录接口 form 编码 422

现象:POST /api/token 用 application/x-www-form-urlencoded 返回 422。

处理:必须 JSON:-H 'Content-Type: application/json' -d '{"username":"admin","password":"q1w2e3r4"}'(见 4.6)。

问题 3:官方文档的 create_token.sh 在 3.5.3 镜像内是坏的

现象:官方 REST API token 文档说用 docker exec <webapp> create_token.sh <username> 生成 token;实测脚本执行报:

could not find task "auth-cli"

根因:脚本内容是 cd /auth_server_app && poetry run task auth-cli,而镜像内 auth-server 的 pyproject 没有注册该 task(list_users.sh 同病)。

处理:3.5.3 拿 API token 的唯一可用路线就是 POST /api/token 登录换 JWT(实测 token 729 字符、有效期 360 分钟)。

问题 4(核心):OCR 成功但全文检索恒 0 命中

现象:OCR 任务全 SUCCESS、media 上 page.txt 文本正确(RECHNUNG ... Stichwort: KAFFEEMUEHLE ...),但 GET /api/search/?q=KAFFEEMUEHLE items 空,Solr 直查也 numFound=0。

诊断链(逐条实测):

  1. Redis celery-task-meta-* 全 SUCCESS → 任务没失败;
  2. 数据库 pages 表:OCR 生成页 text_len 40/122 → 文本已入库;
  3. Solr q=*:* numFound=3,文档里有 text_txt_en:"PAPERMERGE TEXTLAYER MARKER 778811 qual",q=text_txt_en:778811 命中 1 → 索引其实写进去了;
  4. Solr 访问日志显示 webapp 发的查询是裸词:params={q=KAFFEEMUEHLE+AND+user_id:...} hits=0,而 q=text:778811 直接 400 undefined field text;
  5. core 的 solrconfig.xml 默认字段 df=_text_ 是启用态,但默认 managed-schema.xml 没有到 _text_ 的 copyField:q=_text_:KAFFEEMUEHLE hits=0。

根因:salinic(Papermerge 检索组件库)按"裸词 + df=text"查询;官方 compose 用默认 configset 建核,默认 schema 无 *_txt_en → _text_ copyField → 全文检索永远 0 命中。官方文档示例本身带坑。

处理:core schema 增加 <copyField source="*_txt_en" dest="_text_"/>(title/text 都走 *_txt_en 动态规则),RELOAD core 后重新索引。推荐在首启前备好打过补丁的 configset(见 4.1)。

问题 5:上传后 i3worker 刷 NO OCR TEXT FOUND!

现象:i3worker 日志出现:

NO OCR TEXT FOUND! version=1 title=invoice-scan page.number=1

根因:上传即触发一次索引同步,此时 OCR 还没跑,原始页无文本属正常时序噪音;OCR 完成后链尾 i3 会再索引带文本的页。

处理:无需处理;若 OCR 完成后仍不命中,查问题 4/6/7。

问题 6:page_number 段类型冲突,且手改 schema 文件 RELOAD 不生效

现象:修好 copyField 后页面文档仍进不了 Solr,日志:

Exception writing document ... cannot change document field "page_number" from doc values type=SORTED_NUMERIC to inconsistent doc values type=NUMERIC

根因:salinic 把 page_number 按单值 NumericField 写入(NUMERIC docValues),而 core 里已按默认 schema 的多值定义写过旧段(SORTED_NUMERIC),段类型与 schema 不一致即拒写。两个坑中坑:

  1. bind mount 下直接编辑 data/solr/data/pmg/conf/managed-schema.xml 后 action=RELOAD 不生效——RELOAD 后 Schema API 仍返回 multiValued:true(Solr 以版本化 managed schema 为准)。正确做法走 Schema API:POST /solr/pmg/schema {"replace-field":{"name":"page_number","type":"pint","multiValued":false,...}};
  2. 只改 schema 不够,旧段还在:需 delete *:* + commit + optimize(maxSegments=1) 清旧段,本例还叠加了 docker compose up -d --force-recreate solr 才彻底清掉 core 缓存。

验证:单值写入 status=0;i3 index --rebuild EXIT=0,Solr count=8;API 搜索 KAFFEEMUEHLE=3、778811=2 命中(见 4.7 最终回显)。

问题 7(核心):webapp 的 index-schema apply 与 i3worker 冲突,跑错即毁索引写入

现象:首启 entrypoint 的 exec_index_schema_apply 因 Solr 未就绪失败(连接异常 traceback,但服务照常启动)。事后在 webapp 里补跑 paper-cli index-schema apply,返回码 0,此后 i3worker 写 Solr 全部被拒:

cannot change field "document_id" from index options=DOCS_AND_FREQS_AND_POSITIONS to inconsistent index options=NONE

根因:webapp 与 i3worker 各有一套 IndexEntity 字段定义,webapp 侧 apply 把 document_id 等字段改成了不索引/异类型;而 salinic 的 client.add/remove 不检查响应状态码——i3 index --rebuild 退出码 0 但 Solr count=0,静默失败。

处理:用 i3worker 自己的 i3 apply 恢复字段定义(含 copyFields)再 i3 index --rebuild(见 8.3)。教训:3.5.3 官方 compose 场景下 Solr core 归 i3worker 管,不要碰 webapp 的 index-schema apply。

其他小坑速查

现象结论
POST /api/nodes/ 不带 parent_id422 Field required;父节点取 users/me 的 inbox_folder_id
POST /api/users/ 缺 role_ids/group_ids422 两个 missing;补空数组即可
ocrworker 启动 SecurityWarning(root 运行)官方镜像固有行为,可忽略
PAPERMERGE__OCR__LANG_CODES 配了没效果3.5.3 实测无消费代码,OCR 语言真实白名单见《Papermerge 3.5.3 配置参数详解》

十、参考链接

Papermerge 3.5.3 Docker Compose 部署指南 - Quzuo