paddleocr-pdf-api converts PDFs to markdown over an HTTP API. This post deploys it to Azure Container Apps as a single container app with an HTTP ingress.
Using a Self-Hosted PDF OCR API with PaddleOCR describes the API itself. Read that one first.
Engines and workload profiles
Three OCR engines are available, one per image tag. Each image contains its model weights, and Container Apps has a workload profile for each one.
| Engine | Image tag | Models | Needs | Workload profile |
|---|---|---|---|---|
text |
latest-text-baked |
PP-OCRv5 detection and recognition | CPU | Consumption |
vl |
latest-vl-baked |
PaddleOCR-VL-1.6 plus PP-DocLayoutV3 | ~8.5 GB VRAM | Consumption-GPU-NC8as-T4 |
structure |
latest-structure-baked |
PP-StructureV3 | ~10.5 GB VRAM | Consumption-GPU-NC8as-T4 |
The Consumption profile allows 0.25 to 4 vCPU and 0.5 to 8 GiB per replica, in a fixed 1:2 ratio. The two GPU profiles are Consumption-GPU-NC8as-T4 and Consumption-GPU-NC24-A100. A T4 has 16 GB of VRAM, which is enough for both GPU engines.
This deployment uses the text engine on the Consumption profile.
The latest tag contains no model weights. OCR_ENGINE defaults to vl, so it downloads PaddleOCR-VL-1.6 and PP-DocLayoutV3 on first run. That download repeats on every replica replacement, so the baked tags are the ones to deploy here.
Setting up the environment
RG=rg-paddleocr
LOC=swedencentral
ENV=env-paddleocr
APP=paddleocr-api
az group create --name $RG --location $LOC
az containerapp env create \
--name $ENV \
--resource-group $RG \
--location $LOC \
--enable-workload-profiles
Deploying the app
API_KEY=$(openssl rand -hex 16)
az containerapp create \
--name $APP \
--resource-group $RG \
--environment $ENV \
--workload-profile-name Consumption \
--image docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
--target-port 8000 \
--ingress external \
--cpu 4.0 \
--memory 8.0Gi \
--min-replicas 1 \
--max-replicas 1 \
--secrets api-key=$API_KEY \
--env-vars API_KEY=secretref:api-key
This deployment needs no other Azure resource. The models are in the image, and job state is written to the replica's own storage. Mounting Azure Files replaces that storage, and setting DATABASE_URL moves the database to PostgreSQL.
Replica settings
POST /ocr writes the file, records the job as queued, and returns. A background thread does the OCR. No request stays open while a document is processed.
Container Apps adds an HTTP scale rule when you do not define one. It scales on request count, and its minimum is zero replicas. OCR runs without requests, so nothing keeps a replica running.
minReplicas: 0 scales the app to zero while a job is running. Pages already written survive if state persists, and the job resumes on the next request. On ephemeral storage the job and its results are lost.
minReplicas: 1 keeps one replica running, and a job finishes without traffic.
maxReplicas: 1 is not optional here. Jobs are stored in SQLite on the replica's own disk, so each replica has a separate database. A status poll can reach a replica that does not know the job and returns 404. Running more replicas needs PostgreSQL for the records and a shared mount for the uploads. With PostgreSQL every replica sees every queued job, so the replica that claims one may not be the replica that received the upload.
Verifying the deployment
FQDN=$(az containerapp show --name $APP --resource-group $RG \
--query properties.configuration.ingress.fqdn -o tsv)
JOB_ID=$(curl -s -X POST "https://$FQDN/ocr" \
-H "X-API-Key: $API_KEY" \
-F "file=@document.pdf" | jq -r .job_id)
curl -s "https://$FQDN/ocr/$JOB_ID" -H "X-API-Key: $API_KEY"
The models load on the worker thread, so the API answers before OCR can start. A job submitted in that window stays queued until the models are ready.
Making job state survive a restart
/data contains the SQLite database and the uploaded PDFs. Each replica gets ephemeral storage, so both are lost when the replica is replaced. Replacement happens on every revision change.
| Setup | Survives replica loss | Requires |
|---|---|---|
| SQLite on ephemeral storage | No | Nothing |
PostgreSQL through DATABASE_URL
|
Results yes, running job no | A database server |
Azure Files mount on /data
|
Yes | A storage account and share |
PostgreSQL moves the records off the replica, and several replicas can then share them. The uploaded files stay local.
An Azure Files mount keeps both the SQLite database and the uploaded files. Create a storage account and a file share, then register the share with the environment:
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-data \
--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 containerapp env storage set \
--name $ENV \
--resource-group $RG \
--storage-name ocrdata \
--azure-file-account-name $STORAGE \
--azure-file-account-key "$STORAGE_KEY" \
--azure-file-share-name ocr-data \
--access-mode ReadWrite
az containerapp update has no flag for Azure Files mounts, so the mount goes in YAML:
# mount.yaml
properties:
template:
containers:
- name: paddleocr-api
image: docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked
resources:
cpu: 4.0
memory: 8.0Gi
env:
- name: API_KEY
secretRef: api-key
volumeMounts:
- volumeName: ocr-data
mountPath: /data
volumes:
- name: ocr-data
storageName: ocrdata
storageType: AzureFile
mountOptions: "dir_mode=0777,file_mode=0777,uid=0,gid=0,mfsymlinks,nobrl"
scale:
minReplicas: 1
maxReplicas: 1
az containerapp update --name $APP --resource-group $RG --yaml mount.yaml
nobrl is required. Azure Files is an SMB share, and SQLite locks the database with byte-range locks that the SMB client forwards to the server. Those locks fail on SMB, so without nobrl the app cannot open its database and the container does not start.
Locks handled locally are invisible to other clients of the share. That is one more reason maxReplicas stays at 1: two replicas writing the same database file would not see each other's locks.
A completed job survives a replica restart with this mount in place. A job that ran before the mount does not.
Conclusion
Container Apps runs the image without changes. The deployment is a resource group, an environment, and one az containerapp create. Job state that has to outlive a replica adds a storage account, a file share, and nobrl in the mount options.
OCR runs without open requests, so minReplicas: 0 can stop a job in progress, and maxReplicas stays at 1 as long as the database is SQLite.
Bicep template
One file deploys the same resources: the storage account, the share, the environment, the share registration, and the app with the mount.
// main.bicep
param location string = resourceGroup().location
@secure()
param apiKey string
param storageAccountName string = 'stpaddleocr${uniqueString(resourceGroup().id)}'
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-data'
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: 'ocrdata'
properties: {
azureFile: {
accountName: storage.name
accountKey: storage.listKeys().keys[0].value
shareName: 'ocr-data'
accessMode: 'ReadWrite'
}
}
dependsOn: [ share ]
}
resource app 'Microsoft.App/containerApps@2024-03-01' = {
name: 'paddleocr-api'
location: location
properties: {
managedEnvironmentId: env.id
workloadProfileName: 'Consumption'
configuration: {
ingress: {
external: true
targetPort: 8000
}
secrets: [
{ name: 'api-key', value: apiKey }
]
}
template: {
containers: [
{
name: 'paddleocr-api'
image: 'docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked'
resources: {
cpu: json('4.0')
memory: '8.0Gi'
}
env: [
{ name: 'API_KEY', secretRef: 'api-key' }
]
volumeMounts: [
{ volumeName: 'ocr-data', mountPath: '/data' }
]
}
]
volumes: [
{
name: 'ocr-data'
storageName: envStorage.name
storageType: 'AzureFile'
mountOptions: 'dir_mode=0777,file_mode=0777,uid=0,gid=0,mfsymlinks,nobrl'
}
]
scale: {
minReplicas: 1
maxReplicas: 1
}
}
}
}
output fqdn string = app.properties.configuration.ingress.fqdn
az group create --name $RG --location $LOC
az deployment group create \
--resource-group $RG \
--template-file main.bicep \
--parameters apiKey=$API_KEY
The deployment prints the app's hostname as the fqdn output.
Top comments (0)