paddleocr-pdf-api runs as an HTTP server that accepts PDF uploads and returns markdown. Setting RUN_MODE=job runs the same OCR pipeline on a single file and exits. This post runs it that way with docker run and as an Azure Container Apps job.
Using a Self-Hosted PDF OCR API with PaddleOCR describes the server and its endpoints. Deploying PaddleOCR PDF OCR service on Azure Container Apps runs it on Container Apps as a server.
What job mode does
RUN_MODE=job does not start the HTTP server. The process reads the file from standard input and identifies its type. It saves the file to /data/uploads/<job_id>/ and records a queued job in the same database the server uses. The OCR then runs page by page, and each page is written to the database as it finishes.
When the last page is done, the pages are joined in order and printed as the command's output. Redirected to a file, that output is the finished document.
Everything else is log output: the generated job id, the per-page progress lines, and whatever the OCR libraries print. A > redirect does not capture it, so it appears in the terminal while the file receives the markdown alone.
The process exits with one of three codes:
| Exit code | Meaning |
|---|---|
0 |
All pages processed. The markdown is printed. |
1 |
The job did not reach completed. No markdown is printed. |
2 |
No input arrived, or the file is not a type the API accepts. |
Accepted input is the same set the service accepts: PDF, PNG, JPEG, BMP, TIFF and WEBP. A file is identified by content, not by name, because the job never sees a filename.
Running a single file
docker run --rm -i \
-e RUN_MODE=job \
edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
< document.pdf > document.md
-i is required. Without it the container has no standard input to read and exits with code 2. Do not add -t: with standard input redirected from a file, Docker prints the input device is not a TTY and does not start the container.
latest-text-baked is one of multiple images; Docker Hub lists the tags. The same run with a GPU image:
docker run --rm -i --gpus all \
-e RUN_MODE=job \
edgaras0x4e/paddleocr-pdf-api:latest-vl-baked \
< document.pdf > document.md
Every run loads the model into memory. With the non-baked latest tag it is also downloaded first, into a container that is discarded when the run ends, so the download repeats on every run.
What survives the run
--rm with no volume discards /data, which holds the SQLite database and the stored copy of the input. The printed markdown is then the only result, and the job id in the log refers to a row that no longer exists.
Mounting the volume keeps both:
docker run --rm -i \
-e RUN_MODE=job \
-v ocr-data:/data \
edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
< document.pdf > document.md
A server container started on the same volume then serves that job through the API, so GET /jobs lists it and GET /ocr/<job_id>/result returns the pages.
Do not run a job container and a server container on the same volume at the same time. Job mode inserts a queued row before it starts work, and a running server polls the same table for queued rows. The server can claim that row first, and then both processes run the same file.
With DATABASE_URL set, the rows are stored in PostgreSQL instead of inside the container:
docker run --rm -i \
-e RUN_MODE=job \
-e DATABASE_URL=postgresql://user:password@host:5432/ocr \
edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
< document.pdf > document.md
Several job containers can then write to one database, and a server container on the same database serves all their jobs through the API. The double processing above cannot happen here: job mode locks the row before it starts, and a server skips rows it cannot lock.
A directory of files
for f in *.pdf; do
docker run --rm -i -e RUN_MODE=job \
edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
< "$f" > "${f%.pdf}.md"
done
Each iteration starts a container and loads the model again.
Azure Container Apps jobs
A Container Apps job cannot receive a file: it has no HTTP endpoint, and nothing arrives on its standard input. Instead, an Azure Files share is mounted into the job. Input files are uploaded there, and the replica writes the markdown back to the same share.
The environment
RG=rg-paddleocr
LOC=swedencentral
ENV=env-paddleocr
JOB=ocr-job
az group create --name $RG --location $LOC
az containerapp env create \
--name $ENV \
--resource-group $RG \
--location $LOC \
--enable-workload-profiles
The share
STORAGE=stpaddleocr$RANDOM
az storage account create \
--name $STORAGE \
--resource-group $RG \
--location $LOC \
--sku Standard_LRS \
--kind StorageV2
az storage share-rm create \
--name ocr-work \
--storage-account $STORAGE \
--resource-group $RG \
--quota 100
STORAGE_KEY=$(az storage account keys list \
--account-name $STORAGE \
--resource-group $RG \
--query "[0].value" -o tsv)
az storage directory create --share-name ocr-work --name in \
--account-name $STORAGE --account-key "$STORAGE_KEY"
az storage directory create --share-name ocr-work --name out \
--account-name $STORAGE --account-key "$STORAGE_KEY"
az containerapp env storage set \
--name $ENV \
--resource-group $RG \
--storage-name ocrwork \
--azure-file-account-name $STORAGE \
--azure-file-account-key "$STORAGE_KEY" \
--azure-file-share-name ocr-work \
--access-mode ReadWrite
The job
# job.yaml
properties:
environmentId: /subscriptions/<subscription-id>/resourceGroups/rg-paddleocr/providers/Microsoft.App/managedEnvironments/env-paddleocr
workloadProfileName: Consumption
configuration:
triggerType: Manual
replicaTimeout: 1800
replicaRetryLimit: 0
manualTriggerConfig:
parallelism: 1
replicaCompletionCount: 1
template:
containers:
- name: main
image: docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked
resources:
cpu: 4.0
memory: 8.0Gi
command: ["/bin/sh"]
args:
- -c
- >
for f in /work/in/*.pdf; do
[ -e "$f" ] || continue;
python3 -m app.api < "$f" > "/work/out/$(basename "$f" .pdf).md" && rm "$f";
done
env:
- name: RUN_MODE
value: job
- name: DB_PATH
value: /tmp/ocr.db
- name: UPLOAD_DIR
value: /tmp/uploads
volumeMounts:
- volumeName: ocr-work
mountPath: /work
volumes:
- name: ocr-work
storageName: ocrwork
storageType: AzureFile
az containerapp job create --name $JOB --resource-group $RG --yaml job.yaml
One execution processes every PDF in in/, one process and one model load per file. A file is deleted after its markdown is written, so the next execution processes only the files uploaded since the last one.
DB_PATH and UPLOAD_DIR are paths on the replica's own filesystem rather than on the share. SQLite locks its database with byte-range locks, which fail over SMB, so a database file on an Azure Files mount needs nobrl in the mount options.
replicaTimeout: 1800 ends the replica after 30 minutes, whether or not the batch is finished. It has to exceed the time the whole batch takes, not the time one document takes. replicaRetryLimit: 0 means a failed replica is not retried.
Starting an execution
az storage file upload \
--account-name $STORAGE \
--account-key "$STORAGE_KEY" \
--share-name ocr-work \
--source document.pdf \
--path in/document.pdf
az containerapp job start --name $JOB --resource-group $RG
The start command has override flags (--image, --command, --args, --env-vars) that replace the job's whole template for one execution. There is no flag for the volume mount, so an overridden execution has no /work and cannot read the uploaded files. Every execution therefore runs the template as created.
The same two steps from Python:
import subprocess
RG = "rg-paddleocr"
JOB = "ocr-job"
STORAGE = "<storage-account-name>"
STORAGE_KEY = "<storage-account-key>"
def submit(pdf_path, name):
subprocess.run(
["az", "storage", "file", "upload",
"--account-name", STORAGE, "--account-key", STORAGE_KEY,
"--share-name", "ocr-work",
"--source", pdf_path, "--path", f"in/{name}"],
check=True, capture_output=True, text=True,
)
return subprocess.run(
["az", "containerapp", "job", "start",
"--name", JOB, "--resource-group", RG],
check=True, capture_output=True, text=True,
).stdout
Uploading several files before starting one execution processes them all in that execution. Two executions running at once read the same directory, and both can process the same file.
Following the execution
az containerapp job execution list --name $JOB --resource-group $RG -o table
The markdown is a file in out/ on the share:
az storage file download \
--account-name $STORAGE \
--account-key "$STORAGE_KEY" \
--share-name ocr-work \
--path out/document.md \
--dest ./document.md
Logs of finished executions are in the environment's Log Analytics workspace, in the ContainerAppConsoleLogs_CL table:
WS_ID=$(az containerapp env show --name $ENV --resource-group $RG \
--query "properties.appLogsConfiguration.logAnalyticsConfiguration.customerId" -o tsv)
az monitor log-analytics query -w $WS_ID \
--analytics-query "ContainerAppConsoleLogs_CL | where ContainerGroupName_s startswith '<execution-name>' | project TimeGenerated, Log_s | order by TimeGenerated asc" \
-o table
Those lines are what to read when an execution ends with a failure.
Conclusion
Job mode runs the OCR pipeline once and exits. Locally that is one docker run per document, with a volume or PostgreSQL added when the job records are needed after the container is removed.
On Container Apps the same mode is a manual job and an Azure Files share. An execution converts every file in in/, writes the markdown to out/, and stops, and nothing runs until the next start. That is the form for occasional batches of documents.
Bicep template
The template deploys the storage account, the share, the environment, the share registration and the job:
// main.bicep
param location string = resourceGroup().location
param storageAccountName string = 'stpaddleocr${uniqueString(resourceGroup().id)}'
param image string = 'docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked'
var jobCommand = 'for f in /work/in/*.pdf; do [ -e "$f" ] || continue; python3 -m app.api < "$f" > "/work/out/$(basename "$f" .pdf).md" && rm "$f"; done'
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}
resource share 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = {
name: '${storage.name}/default/ocr-work'
properties: { shareQuota: 100 }
}
resource env 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: 'env-paddleocr'
location: location
properties: {
workloadProfiles: [
{ name: 'Consumption', workloadProfileType: 'Consumption' }
]
}
}
resource envStorage 'Microsoft.App/managedEnvironments/storages@2024-03-01' = {
parent: env
name: 'ocrwork'
properties: {
azureFile: {
accountName: storage.name
accountKey: storage.listKeys().keys[0].value
shareName: 'ocr-work'
accessMode: 'ReadWrite'
}
}
dependsOn: [ share ]
}
resource job 'Microsoft.App/jobs@2024-03-01' = {
name: 'ocr-job'
location: location
properties: {
environmentId: env.id
workloadProfileName: 'Consumption'
configuration: {
triggerType: 'Manual'
replicaTimeout: 1800
replicaRetryLimit: 0
manualTriggerConfig: {
parallelism: 1
replicaCompletionCount: 1
}
}
template: {
containers: [
{
name: 'main'
image: image
resources: {
cpu: json('4.0')
memory: '8.0Gi'
}
command: [ '/bin/sh' ]
args: [ '-c', jobCommand ]
env: [
{ name: 'RUN_MODE', value: 'job' }
{ name: 'DB_PATH', value: '/tmp/ocr.db' }
{ name: 'UPLOAD_DIR', value: '/tmp/uploads' }
]
volumeMounts: [
{ volumeName: 'ocr-work', mountPath: '/work' }
]
}
]
volumes: [
{
name: 'ocr-work'
storageName: envStorage.name
storageType: 'AzureFile'
}
]
}
}
}
output storageAccountName string = storage.name
Deploy the template, then create the in and out directories on the new account:
az group create --name $RG --location $LOC
STORAGE=$(az deployment group create \
--resource-group $RG \
--template-file main.bicep \
--query properties.outputs.storageAccountName.value -o tsv)
STORAGE_KEY=$(az storage account keys list \
--account-name $STORAGE \
--resource-group $RG \
--query "[0].value" -o tsv)
az storage directory create --share-name ocr-work --name in \
--account-name $STORAGE --account-key "$STORAGE_KEY"
az storage directory create --share-name ocr-work --name out \
--account-name $STORAGE --account-key "$STORAGE_KEY"

Top comments (0)