Self-hosted OCR microservice powered by PaddleOCR 3.x (PP-OCRv5), exposed as a clean HTTP API behind Bearer-token auth. Built to be consumed by laravel-rag-mcp (or any HTTP client) over a shared Docker network.
Tuned for Indonesian/English legal documents — including phone-photographed scans (skewed, rotated, perspective-distorted).
- POST /extract — single endpoint for both file upload and remote URL (S3/MinIO/RustFS presigned URLs supported)
- Auto PDF type detection — digital PDFs use the text layer (no OCR, ~50 ms); scanned PDFs go through OCR
- Three output formats —
json,text,markdown - Page selector —
all,1,1-5,1,3,5,1-3,7-10 [UNCLEAR]markers for low-confidence lines (no guessing)- Doc orientation auto-correct — handles 0/90/180/270 rotation from phone photos
- Bearer-token auth + structured error responses with stable codes
- CPU and GPU images — same code, two Dockerfiles; toggle via env on the GPU host
- Swagger UI at
/docs
git clone <this-repo> ocr-gateway && cd ocr-gateway
cp .env.example .env
# edit .env: set OCR_API_KEY=<your-secret>
docker build -t ocr-gateway:cpu .
docker run -d --name ocr-gateway \
-p 8000:8000 \
--env-file .env \
--memory=8g --memory-swap=8g \
-v ocr_models:/home/appuser/.paddlex \
--restart unless-stopped \
ocr-gateway:cpuOn macOS (Apple Silicon) add
--platform linux/amd64to bothdocker buildanddocker run. Paddle has no native arm64 wheel that works reliably; Rosetta emulation on amd64 is the proven path.
Test:
curl -s localhost:8000/health
# → {"status":"healthy","engine":"paddleocr","engine_version":"3.5.0",...}
curl -s -X POST localhost:8000/extract \
-H "Authorization: Bearer <your-secret>" \
-F file=@sample.png | jq .
# Or via remote URL:
curl -s -X POST localhost:8000/extract \
-H "Authorization: Bearer <your-secret>" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/document.pdf"}' | jq .Open http://localhost:8000/docs in a browser for interactive Swagger UI.
Prerequisites on the host:
- NVIDIA driver (
nvidia-smiworks) - NVIDIA Container Toolkit
- Sanity check:
docker run --rm --gpus all nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 nvidia-smi
docker build -f Dockerfile.gpu -t ocr-gateway:gpu .Edit .env on the GPU host (re-enable the heavy quality knobs that VRAM lets you afford):
OCR_API_KEY=<your-secret>
OCR_USE_GPU=true
OCR_TEXT_DETECTION_MODEL=PP-OCRv5_server_det
OCR_USE_DOC_UNWARPING=true
OCR_PDF_DPI=300Run with --gpus all:
docker run -d --name ocr-gateway \
--gpus all \
-p 8000:8000 \
--env-file .env \
-v ocr_models:/home/appuser/.paddlex \
--restart unless-stopped \
ocr-gateway:gpuThe
OCR_USE_GPU=trueswitch only works inside the GPU image. The CPU image's paddlepaddle wheel has no CUDA bindings; setting the flag there will not magically enable GPU.
Liveness + engine info.
{
"status": "healthy",
"version": "1.0.0",
"engine": "paddleocr",
"engine_version": "3.5.0",
"gpu_available": false,
"mkldnn_enabled": false,
"supported_formats": ["pdf","jpg","jpeg","png","tiff","bmp","webp"],
"max_file_size_mb": 100,
"max_pages": 1000
}Returns 503 {"status":"warming"} during the first ~5-10 seconds while models load.
Either upload a file or pass a URL. Use exactly one.
Multipart form (multipart/form-data):
| Field | Required | Default | Description |
|---|---|---|---|
file |
one of | — | PDF or image upload |
url |
one of | — | HTTP/HTTPS URL to a file (presigned URLs OK) |
language |
no | OCR_LANGUAGE |
en, ch, latin, etc. |
output_format |
no | json |
json, text, or markdown |
pages |
no | all |
all, 1, 1-5, 1,3,5, 1-3,7-10 |
JSON body (alternative — convenient when you only have a URL):
{
"url": "https://rustfs.example.com/bucket/doc.pdf?presigned=...",
"language": "en",
"output_format": "json",
"pages": "all"
}Successful response (HTTP 200):
{
"success": true,
"engine": "paddleocr",
"method": "ocr",
"pages_processed": 3,
"total_pages": 3,
"output_format": "json",
"result": {
"pages": [
{
"page": 1,
"text": "STANDARD OPERATING PROCEDURE\nPT Maju Bersama...",
"confidence": 0.94,
"word_count": 245,
"has_table": false,
"has_unclear": false
}
],
"full_text": "STANDARD OPERATING PROCEDURE\n...",
"metadata": {
"file_name": "doc.pdf",
"file_size_bytes": 204800,
"processing_time_ms": 3420,
"source": "upload"
}
}
}method is either:
text-layer— PDF had a text layer; extracted with PyMuPDF, no OCR (super fast)ocr— actual OCR was run
Error response (any non-2xx):
{
"success": false,
"error": "FILE_TOO_LARGE",
"detail": "File size 75MB exceeds maximum 50MB",
"status_code": 413
}Stable error codes: MISSING_INPUT, BOTH_INPUT, UNSUPPORTED_FORMAT, FILE_TOO_LARGE, TOO_MANY_PAGES, INVALID_PAGES, INVALID_PDF, INVALID_URL, URL_UNREACHABLE, URL_TIMEOUT, INVALID_OUTPUT_FORMAT, OCR_FAILED, UNAUTHORIZED, VALIDATION_ERROR.
Lists supported input/output formats. Useful for client introspection.
Swagger UI / ReDoc.
curl -X POST http://localhost:8000/extract \
-H "Authorization: Bearer $OCR_API_KEY" \
-F file=@receipt.jpg \
-F output_format=textcurl -X POST http://localhost:8000/extract \
-H "Authorization: Bearer $OCR_API_KEY" \
-F file=@doc.pdf \
-F pages=1-5 \
-F output_format=markdowncurl -X POST http://localhost:8000/extract \
-H "Authorization: Bearer $OCR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://rustfs.example.com/bucket/contract.pdf",
"output_format": "json",
"pages": "all"
}'OCR jobs may take minutes for large scanned PDFs. Run them inside a queue worker, not a sync HTTP request:
// app/Jobs/OcrDocument.php
public int $timeout = 1800; // 30 minutes
public function handle(): void
{
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.ocr.key'),
])->timeout(1800)->acceptJson()->post(
config('services.ocr.url') . '/extract',
[
'url' => $this->presignedUrl,
'output_format' => 'json',
'language' => 'en',
]
);
if (! $response->successful()) {
throw new RuntimeException(
"OCR failed: {$response->json('error')} - {$response->json('detail')}"
);
}
$text = $response->json('result.full_text');
// ... store / index / etc.
}In config/queue.php bump retry_after to ≥ 1900. In Horizon, set timeout: 1900 on the relevant supervisor.
All settings come from environment variables (loaded from .env via --env-file).
| Variable | Default | Purpose |
|---|---|---|
OCR_API_KEY |
— (required) | Bearer token clients must present |
OCR_LANGUAGE |
en |
PaddleOCR language code (default when client omits language) |
OCR_USE_GPU |
false |
Set true only on GPU image |
OCR_ENABLE_MKLDNN |
false |
Disabled — Paddle 3.x PIR + OneDNN has unimplemented op coverage |
OCR_USE_ANGLE_CLS |
true |
Per-line orientation classifier |
OCR_USE_DOC_ORIENTATION_CLASSIFY |
true |
Detects 0/90/180/270 rotation (phone photos) |
OCR_USE_DOC_UNWARPING |
false |
UVDoc perspective-correction. ~2 GB peak memory; enable on GPU or hosts with ≥16 GB |
OCR_TEXT_DETECTION_MODEL |
PP-OCRv5_mobile_det |
Use PP-OCRv5_server_det on GPU/large-RAM hosts for slightly better recall |
OCR_DET_DB_BOX_THRESH |
0.3 |
Detection threshold |
OCR_CPU_THREADS |
4 |
OMP/MKL thread count |
OCR_PDF_DPI |
200 |
Rasterization DPI for scanned PDFs (300 = sharper, ~2× memory) |
OCR_MAX_FILE_SIZE_MB |
50 |
Hard cap on upload + remote download |
OCR_MAX_PAGES |
100 |
PDFs over this return TOO_MANY_PAGES |
OCR_URL_DOWNLOAD_TIMEOUT |
30 |
Seconds to wait when fetching from URL |
OCR_MIN_TEXT_LENGTH |
50 |
Threshold (chars/page) for "is this PDF text-based?" |
OCR_UNCLEAR_THRESHOLD |
0.5 |
Lines below this confidence become [UNCLEAR] |
LOG_LEVEL |
INFO |
Rough numbers for a typical 11-12 pt Indonesian/English legal document.
| Scenario | CPU (8-core, mobile_det, DPI 200) | GPU (GTX 1060+, server_det, DPI 300) |
|---|---|---|
| Clean image, 1 page | ~600 ms | ~50–100 ms |
| Digital PDF with text layer | ~50 ms | ~50 ms (no OCR) |
| Scanned PDF, 1 page | ~10–25 s | ~1–3 s |
| Scanned PDF, 50 pages | ~10–15 min | ~1–3 min |
| Scanned PDF, 100 pages | ~20–30 min | ~3–5 min |
Recognition (PP-OCRv5_server_rec) is always server-grade, regardless of detection model — text that gets detected is always read at high accuracy.
The Laravel client expects:
OCR_GATEWAY_URL=http://ocr-gateway:8000
OCR_GATEWAY_KEY=<same value as the server's OCR_API_KEY>Steps:
- Deploy this image as a Dokploy service named
ocr-gateway(the hostname must match). - Attach the service to the
dokploy-networkso it shares a network withlaravel-rag-mcp. - Set
OCR_API_KEYserver-side andOCR_GATEWAY_KEYclient-side to the same value. - Mount a persistent volume on
/home/appuser/.paddlexto avoid re-downloading model weights on every restart.
Verify from inside the Laravel container:
curl http://ocr-gateway:8000/healthocr-gateway/
├── Dockerfile # CPU build (python:3.11-slim + paddlepaddle CPU)
├── Dockerfile.gpu # GPU build (CUDA 12.6 runtime + paddlepaddle-gpu)
├── requirements.txt # CPU deps
├── requirements-gpu.txt # GPU deps (paddlepaddle-gpu via Paddle's index)
├── .env.example
├── README.md # this file
└── app/
├── main.py # FastAPI app, lifespan, exception handlers
├── config.py # pydantic-settings; reads .env
├── dependencies.py # require_bearer auth dependency
├── routers/
│ ├── extract.py # POST /extract
│ └── health.py # GET /health, GET /formats
├── services/
│ ├── detector.py # PDF text-layer detection (PyMuPDF)
│ ├── pdf_extractor.py # text-layer extraction (no OCR)
│ ├── pdf_rasterizer.py # PDF page → PNG (PyMuPDF)
│ ├── ocr_service.py # PaddleOCR 3.x wrapper (singleton per lang)
│ ├── url_fetcher.py # remote URL download with size cap
│ └── preprocessor.py # legacy CV preprocessor (not in runtime path)
├── models/
│ └── schemas.py # Pydantic request/response schemas
└── utils/
├── errors.py # OcrGatewayError + structured error handler
└── pages.py # page selector parser
Container ran out of memory mid-inference. On Rosetta-emulated linux/amd64 (Apple Silicon Macs), peak memory for server_det + UVDoc + DPI 300 can exceed 8 GB.
Fix (any of these):
- Lower
OCR_PDF_DPIto200 - Set
OCR_TEXT_DETECTION_MODEL=PP-OCRv5_mobile_det - Set
OCR_USE_DOC_UNWARPING=false - Bump
--memory=12g(only if Docker Desktop has enough RAM allocated) - Run on a real GPU host (no Rosetta overhead)
The default .env.example ships with the safe combo.
Models are downloaded from Hugging Face on first request. If the host has no internet (corp proxy, air-gapped), download fails and /health stays at warming. Either:
- Allow outbound to
huggingface.coandbcebos.com - Pre-bake weights into the image at build time and mount as volume
Some upstream proxy (Cloudflare, Nginx) timed out the open connection while the server was still working. The OCR result was produced but never delivered. Solution: call /extract from a server-to-server context (e.g. Laravel queue worker), not from a browser-proxied client. There is no client-side proxy timeout when both services share a Docker network.
A line was detected but recognition confidence fell below OCR_UNCLEAR_THRESHOLD (default 0.5). The original recognized text is still preserved per-line in result.pages[i].lines[j].text; only the joined full_text substitutes [UNCLEAR]. Lower the threshold to 0.3 if you'd rather see noisy text than skipped tokens.
A harmless warning from PaddleOCR 3.x: when you pass an explicit model name (OCR_TEXT_DETECTION_MODEL=...), the language hint is overridden by the model itself. Recognition still works for the model's supported languages.
MIT — same as the upstream PaddleOCR. Models are downloaded from Hugging Face under their respective licenses.