GPU-accelerated PDF OCR API that extracts text from scanned documents and returns markdown in JSON.
5.4K
A self-hosted PDF OCR API powered by PaddleOCR models (PaddleOCR-VL by default). Runs via Docker, processes PDFs page-by-page, and returns markdown content in JSON responses. Good support (not perfect) for Latvian and Lithuanian languages.
The API runs one of three OCR engines, selected with the OCR_ENGINE variable. All engines share the same API and job flow:
OCR_ENGINE | Models | Output | Hardware |
|---|---|---|---|
vl (default) | PaddleOCR-VL-1.6 (0.9B) + PP-DocLayoutV3 | Markdown, full document understanding | GPU, ~8.5GB VRAM |
text | PP-OCRv5 server detection + recognition | Plain text lines, no layout markup | GPU or CPU |
structure | PP-StructureV3 layout parsing (tables, formulas, reading order), no VLM | Markdown | GPU, ~10.5GB VRAM |
All engines accept the same input formats: PDF, PNG, JPG, JPEG, BMP, TIFF, WEBP.
Each engine has its own baked image (models included, no first-run download):
| Image tag | Engine | Runs on |
|---|---|---|
latest-vl-baked | vl | GPU |
latest-structure-baked | structure | GPU |
latest-text-baked | text | CPU |
Baked images preselect their engine, so OCR_ENGINE does not need to be set. The non-baked latest image is GPU-based and works with any engine; it downloads the selected engine's models at runtime.
Every image needs Docker. The GPU images also need the NVIDIA Container Toolkit and an NVIDIA GPU:
| Image | Needs |
|---|---|
latest | Docker + NVIDIA GPU (for vl engine) |
latest-vl-baked | Docker + NVIDIA GPU (~8.5GB VRAM) |
latest-structure-baked | Docker + NVIDIA GPU (~10.5GB VRAM) |
latest-text-baked | Docker |
Using the Docker Hub image:
services:
paddleocr:
image: edgaras0x4e/paddleocr-pdf-api:latest
ports:
- "8099:8000"
environment:
- OCR_ENGINE=vl # or text, or structure
volumes:
- ocr-data:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
volumes:
ocr-data:
docker compose up -d
Or build from source:
git clone https://github.com/Edgaras0x4E/paddleocr-pdf-api.git && cd paddleocr-pdf-api
docker compose up --build -d
The API will be available at http://localhost:8099. On first startup the model (~2GB) is downloaded and loaded into GPU memory. The API accepts requests immediately, but jobs will start processing once the model is ready.
Baked images (no first-run download): for scale-to-zero or cold-start-sensitive deployments, use your engine's baked tag instead (see OCR engines). The models are pre-baked into the image, so the container starts without downloading anything; cold-start warmup is only the model load into memory.
Also accepts single image files as a bonus: .png, .jpg, .jpeg, .bmp, .tif, .tiff, .webp (processed as a 1-page job).
curl -X POST http://localhost:8099/ocr -F "[email protected]"
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"filename": "document.pdf",
"status": "queued"
}
curl http://localhost:8099/ocr/{job_id}
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"filename": "document.pdf",
"status": "processing",
"total_pages": 185,
"processed_pages": 42,
"error": null
}
curl http://localhost:8099/ocr/{job_id}/pages/1
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"page_num": 1,
"markdown": "## Chapter 1\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit..."
}
curl http://localhost:8099/ocr/{job_id}/result
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"filename": "document.pdf",
"status": "completed",
"total_pages": 185,
"processed_pages": 185,
"pages": [
{"page_num": 1, "markdown": "## Chapter 1\n\nLorem ipsum dolor sit amet..."},
{"page_num": 2, "markdown": "..."}
]
}
curl http://localhost:8099/jobs
{
"jobs": [
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"filename": "document.pdf",
"status": "completed",
"total_pages": 185,
"processed_pages": 185
}
]
}
curl -X POST http://localhost:8099/ocr/{job_id}/cancel
{
"job_id": "994e7b398bb44d8ab5eade4d2ef57a15",
"status": "cancelling"
}
curl -X DELETE http://localhost:8099/ocr/{job_id}
{
"status": "deleted"
}
| Method | Endpoint | Description |
|---|---|---|
POST | /ocr | Upload a PDF for processing |
GET | /ocr/{job_id} | Get job status and progress |
GET | /ocr/{job_id}/pages/{page_num} | Get markdown for a specific page |
GET | /ocr/{job_id}/result | Get all completed pages |
POST | /ocr/{job_id}/cancel | Cancel a queued or running job |
DELETE | /ocr/{job_id} | Delete a job and its data |
GET | /jobs | List all jobs |
Environment variables set in docker-compose.yml:
| Variable | Default | Description |
|---|---|---|
API_KEY | (empty) | Optional API key. When set, all requests must include an X-API-Key header |
OCR_ENGINE | vl | OCR engine: vl, text, or structure (see OCR engines) |
OCR_DPI | 200 | DPI for PDF page rendering |
OCR_MARKDOWN_IGNORE_LABELS | number, footnote, header, header_image, footer, footer_image, aside_text | Comma-separated layout labels dropped from the markdown (vl and structure engines). |
DATABASE_URL | (empty) | Empty uses SQLite at DB_PATH. A postgresql://... URL uses PostgreSQL instead. |
RUN_MODE | server | server is the always-on API. job reads one file from stdin, processes it, and exits. |
DB_PATH | /data/ocr.db | SQLite database path (used when DATABASE_URL is empty) |
UPLOAD_DIR | /data/uploads | Upload storage path |
By default the API stores jobs and results in SQLite at /data/ocr.db, persisted on the /data volume. No configuration is needed.
To use PostgreSQL instead, set DATABASE_URL:
environment:
- DATABASE_URL=postgresql://user:password@host:5432/ocr
The schema is created automatically on startup. With PostgreSQL the database is external, so the API is stateless apart from the uploaded files held on /data during processing. Multiple server containers can share one PostgreSQL database.
By default the container runs as an always-on server (RUN_MODE=server). Set RUN_MODE=job to instead process a single file and exit. The file is piped in on stdin; the result is written to the same database as server mode and read back through the API:
docker run --rm -i --gpus all \
-e RUN_MODE=job \
edgaras0x4e/paddleocr-pdf-api:latest < document.pdf
The container reads the file from stdin, runs the same OCR pipeline, saves the result to the database, and exits (0 on success, non-zero on failure). It does not start the HTTP server. The baked image variant avoids the model download on each run, which suits repeated one-shot jobs.
To keep the result, point the run at a persistent database:
# PostgreSQL (external DB, nothing mounted)
docker run --rm -i --gpus all \
-e RUN_MODE=job -e DATABASE_URL=postgresql://user:password@host:5432/ocr \
edgaras0x4e/paddleocr-pdf-api:latest-vl-baked < document.pdf
# SQLite (mount the /data volume so the database persists)
docker run --rm -i --gpus all \
-e RUN_MODE=job -v ocr-data:/data \
edgaras0x4e/paddleocr-pdf-api:latest-vl-baked < document.pdf
When enabled, cropped image regions (photos, charts, seals, logos) detected by the layout model are sent to an OpenAI-compatible vision model, and the returned description is inlined in the page markdown as a > **[Label]** ... blockquote. Disabled by default - the original behavior (stripping image tags) is preserved.
| Variable | Default | Description |
|---|---|---|
IMAGE_DESCRIPTION_ENABLED | false | Master switch. When false, images are stripped as before. |
IMAGE_DESCRIPTION_PROVIDER | openai | openai (any OpenAI(base_url=…)-compatible endpoint) or azure (uses AzureOpenAI). |
IMAGE_DESCRIPTION_API_URL | https://api.openai.com/v1 | Base URL. For azure, the resource endpoint, e.g. https://<name>.cognitiveservices.azure.com. |
IMAGE_DESCRIPTION_API_KEY | (empty) | Bearer / API key. Local backends accept any placeholder. |
IMAGE_DESCRIPTION_API_VERSION | (empty) | Azure-only, e.g. 2025-01-01-preview (chat) or 2025-04-01-preview (responses). |
IMAGE_DESCRIPTION_API_MODE | chat_completions | chat_completions (universal) or responses (OpenAI-native / Azure). |
IMAGE_DESCRIPTION_MODEL | gpt-5.5 | Model name (or Azure deployment name). |
IMAGE_DESCRIPTION_PROMPT | built-in neutral prompt | Default prompt used when no per-label override is set. |
IMAGE_DESCRIPTION_PROMPT_<LABEL> | (empty) | Per-label override, e.g. IMAGE_DESCRIPTION_PROMPT_CHART="Extract numeric data as a markdown table.". Label is the uppercase block_label (IMAGE, CHART, SEAL, HEADER_IMAGE, FOOTER_IMAGE). |
IMAGE_DESCRIPTION_LABELS | image,chart,seal,header_image,footer_image | Comma-separated labels to describe. table and formula are always skipped (PaddleOCR renders them natively). |
IMAGE_DESCRIPTION_MIN_PIXELS | 10000 | Skip crops smaller than this area (w × h). Filters out bullet icons. |
IMAGE_DESCRIPTION_MAX_EDGE_PX | 1568 | Downscale longest edge before sending. 0 disables. |
IMAGE_DESCRIPTION_MAX_PER_PAGE | 10 | Cap of described images per page. |
IMAGE_DESCRIPTION_TIMEOUT | 60 | Seconds per request. |
IMAGE_DESCRIPTION_MAX_RETRIES | 2 | Retries on transient errors. |
IMAGE_DESCRIPTION_ON_ERROR | skip | skip, placeholder (inserts [image description unavailable]), or fail. |
Example docker-compose.yml override:
environment:
- IMAGE_DESCRIPTION_ENABLED=true
- IMAGE_DESCRIPTION_PROVIDER=azure
- IMAGE_DESCRIPTION_API_URL="https://<your-resource>.cognitiveservices.azure.com"
- IMAGE_DESCRIPTION_API_VERSION="2025-04-01-preview"
- IMAGE_DESCRIPTION_API_MODE=responses
- IMAGE_DESCRIPTION_MODEL="gpt-5.5"
- IMAGE_DESCRIPTION_API_KEY="<your-key>"
- IMAGE_DESCRIPTION_PROMPT_CHART="Extract all data points from this chart as a markdown table."
Uncomment the environment section in docker-compose.yml:
environment:
- API_KEY=your-secret-key
Then restart:
docker compose down && docker compose up -d
All requests must then include the header:
curl -H "X-API-Key: your-secret-key" http://localhost:8099/jobs
The /data volume stores the SQLite database and uploaded PDFs. This is a named Docker volume (ocr-data) that persists across container restarts and rebuilds. When DATABASE_URL points at PostgreSQL, the database lives in PostgreSQL and /data only holds uploaded files during processing.
OCR_MARKDOWN_IGNORE_LABELS to control which layout labels are dropped from the markdown output. (#1)OCR_ENGINE to select the OCR engine: vl (default), text, or structure, each with its own baked image. (#1):latest-vl-baked / :v0.4.1-vl-baked (was :latest-baked / :v0.4.0-baked); latest-baked remains an alias.DATABASE_URL. SQLite remains default.job run mode (RUN_MODE=job): the container reads a file, processes it, saves the result to the database, and exits.paddleocr 3.6.0).:latest-baked / :v0.3.0-baked) with the model pre-baked into the image - no first-run download, for fast cold starts and scale-to-zero deployments..png, .jpg, .jpeg, .bmp, .tif, .tiff, .webp) as 1-page jobs.MIT
Content type
Image
Digest
sha256:b69699dcc…
Size
5.9 GB
Last updated
3 months ago
docker pull edgaras0x4e/paddleocr-pdf-api