Tools,

Docspell OCR, tested on scanned PDFs

10 of 80

pages got text from an 80-page scan

Get the fix

The official compose file works

The Docspell docs point to one compose file. I ran it as printed, with both ports on 127.0.0.1.

1,881 of 1,955 true words found

49 to 57 s for 10 pages

Docspell 0.43.0 started in 10 seconds. A typed cover page in front kept all 13 of its words. Before you open it to a network, change the secrets its comments name.

The whole compose file that ran
name: docspell
services:
  # The restserver and joex containers defined here are configured
  # using env variables. Both must connect to the same database and
  # solr instance. More information on configuring can be found here:
  # https://docspell.org/docs/configure
  #
  # Please replace the values of the following with a custom secret
  # string:
  #
  # - DOCSPELL_SERVER_ADMIN__ENDPOINT_SECRET
  # - DOCSPELL_SERVER_AUTH_SERVER__SECRET
  # - DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE
  #   (use the same value at the consumedir container!)
  #
  # After creating an account, you may want to set signup mode to
  # "closed" or to "invite". When using invite, you must also set
  # DOCSPELL_SERVER_BACKEND_SIGNUP_NEW__INVITE__PASSWORD to some
  # secret.
  restserver:
    image: ghcr.io/docspell/restserver:latest
    hostname: docspell-restserver
    container_name: docspell-restserver
    restart: unless-stopped
    ports:
      - "127.0.0.1:7880:7880"
    environment:
      TZ: 'Europe/Berlin'
      DOCSPELL_SERVER_INTERNAL__URL: 'http://docspell-restserver:7880'
      DOCSPELL_SERVER_ADMIN__ENDPOINT_SECRET: 'admin123'
      DOCSPELL_SERVER_AUTH_SERVER__SECRET: ''
      DOCSPELL_SERVER_BACKEND_JDBC_PASSWORD: 'dbpass'
      DOCSPELL_SERVER_BACKEND_JDBC_URL: 'jdbc:postgresql://db:5432/dbname'
      DOCSPELL_SERVER_BACKEND_JDBC_USER: 'dbuser'
      DOCSPELL_SERVER_BIND_ADDRESS: '0.0.0.0'
      DOCSPELL_SERVER_FULL__TEXT__SEARCH_ENABLED: 'true'
      DOCSPELL_SERVER_FULL__TEXT__SEARCH_SOLR_URL: 'http://docspell-solr:8983/solr/docspell'
      DOCSPELL_SERVER_INTEGRATION__ENDPOINT_ENABLED: 'true'
      DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_ENABLED: 'true'
      DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE: 'integration-password123'
      DOCSPELL_SERVER_BACKEND_SIGNUP_MODE: 'open'
      DOCSPELL_SERVER_BACKEND_SIGNUP_NEW__INVITE__PASSWORD: ''
      DOCSPELL_SERVER_BACKEND_ADDONS_ENABLED: 'false'

  joex:
    image: ghcr.io/docspell/joex:latest
    hostname: docspell-joex
    container_name: docspell-joex
    ## For more memory add corresponding arguments, like below. Also see
    ## https://docspell.org/docs/configure/#jvm-options
    # command:
    #   - -J-Xmx3G
    restart: unless-stopped
    environment:
      TZ: 'Europe/Berlin'
      DOCSPELL_JOEX_APP__ID: 'joex1'
      DOCSPELL_JOEX_PERIODIC__SCHEDULER_NAME: 'joex1'
      DOCSPELL_JOEX_SCHEDULER_NAME: 'joex1'
      DOCSPELL_JOEX_BASE__URL: 'http://docspell-joex:7878'
      DOCSPELL_JOEX_BIND_ADDRESS: '0.0.0.0'
      DOCSPELL_JOEX_FULL__TEXT__SEARCH_ENABLED: 'true'
      DOCSPELL_JOEX_FULL__TEXT__SEARCH_SOLR_URL: 'http://docspell-solr:8983/solr/docspell'
      DOCSPELL_JOEX_JDBC_PASSWORD: 'dbpass'
      DOCSPELL_JOEX_JDBC_URL: 'jdbc:postgresql://db:5432/dbname'
      DOCSPELL_JOEX_JDBC_USER: 'dbuser'
      DOCSPELL_JOEX_ADDONS_EXECUTOR__CONFIG_RUNNER: 'docker,trivial'
      DOCSPELL_JOEX_CONVERT_HTML__CONVERTER: 'weasyprint'
    ports:
      - "127.0.0.1:7878:7878"
    ## Uncomment when using the "docker" runner with addons
    # volumes:
    #   - /var/run/docker.sock:/var/run/docker.sock
    #   - /tmp:/tmp

  # The consumedir container watches a directory for files to upload
  # to docspell restserver. This uses the `dsc` tool. For information
  # on the available options, see `dsc --help`.
  # https://github.com/docspell/dsc
  #
  # The value after `Docspell-Integration` must match the secret
  # specified at the restserver via
  # DOCSPELL_SERVER_INTEGRATION__ENDPOINT_HTTP__HEADER_HEADER__VALUE.
  #
  # The --not-matches "**/.*" option is to exclude hidden files.
  consumedir:
    image: docspell/dsc:latest
    container_name: docspell-consumedir
    command:
      - dsc
      - "-d"
      - "http://docspell-restserver:7880"
      - "watch"
      - "--delete"
      - "-ir"
      - "--not-matches"
      - "**/.*"
      - "--header"
      - "Docspell-Integration:integration-password123"
      - "/opt/docs"
    restart: unless-stopped
    volumes:
      - ./docs:/opt/docs

  db:
    image: postgres:18.7
    container_name: postgres_db
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/
    environment:
      POSTGRES_USER: 'dbuser'
      POSTGRES_PASSWORD: 'dbpass'
      POSTGRES_DB: 'dbname'

  solr:
    image: solr:10
    container_name: docspell-solr
    restart: unless-stopped
    volumes:
      - solr_data:/var/solr
    environment:
      SOLR_OPTS: "-Dsolr.modules=analysis-extras"
    command:
      - bash
      - -c
      - 'precreate-core docspell; exec solr start -f --user-managed'
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8983/solr/docspell/admin/ping"]
      interval: 1m
      timeout: 10s
      retries: 2
      start_period: 30s

volumes:
  postgres_data:
  solr_data:

Long scans stop at 5 minutes

Docspell gives OCRmyPDF 5 minutes a file, one page at a time. I sent 80 scanned pages, and the job log said:

PDF conversion failed: Timed out after: 300000 ms. Go without PDF file

10 of 80 pages got text

0 pages with a text layer in the PDF

Docspell then ran its own OCR on the first 10 pages only, and stored them out of order. The other 70 pages have no text.

Fix: raise the OCRmyPDF timeout

Save this as docker-compose.override.yml next to the compose file, then run docker compose up -d. Compose reads it on its own.

services:
  joex:
    environment:
      DOCSPELL_JOEX_CONVERT_OCRMYPDF_COMMAND_TIMEOUT: "20 minutes"

80 of 80 pages got text

15,048 of 15,640 true words found

OCRmyPDF needed 6 minutes for the 80 pages. Other settings work the same way: in the config key, dots become _ and dashes become __.

There is no default login

The compose file sets open signup, so make an account at /app/register. I tried admin and admin, and the answer was:

Login failed.

A collective and a user with the same name log in with that one name. I saved this as login.sh:

curl -s -X POST "$DS/api/v1/open/auth/login" -H "Content-Type: application/json" \
  -d "{\"account\": \"tack\", \"password\": \"$DS_PASSWORD\"}" | jq -r .token

The token lasts 5 minutes. A script that waits on long OCR jobs has to log in again.

Uploads are read as German

An upload with no language gets the collective's language, German by default. Name it on each upload. I saved this as upload.sh:

META="{\"multiple\": false${2:+, \"language\": \"$2\"}}"
curl -s -H "X-Docspell-Auth: $DS_TOKEN" \
  -F "meta=$META;type=application/json" -F "file=@$1" \
  "$DS/api/v1/sec/upload/item" | jq -r .message
Words right, by upload language
Language 10 English pages 3 German pages
deu, the default 1,865 1,845
eng 1,881 1,622

With eng, the German pages lost all 206 umlauts (ä, ö, ü, ß). With deu, 192 were right. Docspell sends one language to OCRmyPDF, so a file with two languages gets one of them.

On a Mac, skip the arm64 image

The official images are amd64 only. On Apple silicon, Docker prints a platform warning and runs them under emulation. Every number above ran that way.

Docker Hub has arm64 images up to v0.42.0, from August 2024. There, OCRmyPDF stopped after 1 second:

Tesseract failed to report available languages.

Docspell's own OCR then read the pages out of order, and the PDF got no text layer.

Get the text with two calls

Docspell keeps the OCR text with each attachment. I saved this as text.sh:

H="X-Docspell-Auth: $DS_TOKEN"
ATT=$(curl -s -H "$H" "$DS/api/v1/sec/item/$1" | jq -r '.attachments[0].id')
curl -s -H "$H" "$DS/api/v1/sec/attachment/$ATT/meta" | jq -r .content

Search works too: content:Hatton found the 4 uploads that hold the scan.

Docspell vs Paperless-ngx, same files

I ran Paperless-ngx and Papermerge on the same scans earlier, each with its own defaults:

Words found, English set to eng
App 10-page scan Cover + scan
Docspell 0.43.0 1,881 1,881
Paperless-ngx 3.4.2 1,874 2
Papermerge 3.5.3 1,864 1,864

Paperless-ngx skips OCR when a page already has text. Docspell runs OCRmyPDF with --skip-text, so only the cover is skipped.

Want Markdown out? Use the Actor

Docspell is free and files your papers. Our OCR Actor gives Markdown with no server. On the same files:

1,874 of 1,955 English words

192 of 206 German umlauts

It also kept the 13 cover words. It costs $0.006 a page.

How I tested it
  • Scan: the 10-page PDF of OCR in n8n, proofread Wikisource book scans with no text layer. 1,955 true words.
  • Long scan: that PDF 8 times in one file, 80 pages, 15,640 true words. With no fix, its stored text was scored page by page, order ignored.
  • Cover page: a 13-word typed sheet in front of the scan, the file of AnythingLLM OCR.
  • German: 3 proofread de.wikisource pages, the file of Paperless-ngx OCR. 1,887 words, 206 umlauts.
  • Score: true words found in order, with the scorer of the tessdata test.
  • Docspell 0.43.0 (Tesseract 5.5.0, OCRmyPDF 16.13.0), from the official compose file, with Postgres 18.7 and Solr 10. v0.42.0: the Docker Hub arm64 images.
  • Mac with Apple silicon, Docker 29.8.2. Joex on 2 CPUs. Times are from the job start to its end.
  • The code blocks above are the files that ran. Paperless-ngx, Papermerge and Actor rows: my earlier runs on the same files.
What I did not test
  • Email import, the watch folder, and addons.
  • The web UI, tags and the metadata guesses.
  • Office files, images, and handwriting.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

Why does Docspell OCR only the first 10 pages?

OCRmyPDF hit Docspell's 5-minute limit, so Docspell ran its own OCR on the first 10 pages only. Raise DOCSPELL_JOEX_CONVERT_OCRMYPDF_COMMAND_TIMEOUT: with 20 minutes, every page of the test scan got text.

What is the Docspell default login?

There is none. The official compose file sets open signup, so you make an account at /app/register. admin and admin got Login failed.

Why does Docspell read my English scans as German?

An upload with no language gets the collective's language, German by default. Name the language on each upload, as upload.sh on this page does.