Build a GPU-Accelerated OCR API with PaddleOCR on Civo Kubernetes

Turn scanned documents into structured JSON automatically with PaddleOCR on Civo Kubernetes.

13 minutes reading time

Written by

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer at GoCardless

At some point, the scanned documents start piling up: invoices from suppliers, intake forms from clients, printed reports from a system that does not have an export button. The only way to get the data out of them is to open each one and type what you see into something else, and that works fine until the volume makes it unsustainable.

The obvious next move is an LLM, send the image, ask for the fields, parse what comes back, and for a while that seems reasonable until you get a wrong total or a date that got transposed, and for a pipeline feeding a financial system, those are not small errors because a language model predicts what the text probably says, not reads it.

PaddleOCR locates text on the page and reads it directly. For most documents, the same image produces the same JSON output every time.

In this tutorial, you will deploy PaddleOCR on a Civo L40S GPU pod with a Watcher pod that monitors a Civo Object Store bucket:

  • A FastAPI service running on a Civo L40S GPU pod that returns structured JSON with every line of text and its bounding box
  • A Watcher pod that monitors a Civo Object Store bucket and automatically sends new images to the OCR service
  • A fully automated pipeline where you drop an image into the pending folder, and JSON lands in the results folder with no manual API calls or client code

By the end, you will have a fully automated document extraction pipeline running on Civo Kubernetes.

What you’ll build

A fully automated document extraction pipeline on Civo Kubernetes that picks up scanned images from a bucket and returns structured JSON with every line of text and its position on the page.

The pipeline works in four steps:

  1. You upload an image to Civo Object Store into the pending folder
  2. A Watcher pod notices the new file and forwards it to the OCR service over an internal ClusterIP connection
  3. The OCR service runs PaddleOCR on a Civo L40S GPU pod and pulls out every line of text with its bounding box
  4. The result lands back in the results folder in Object Store as structured JSON

The core components:

  • Civo Kubernetes cluster with separate CPU and GPU node pools
  • A FastAPI OCR service running on an L40S GPU pod, handling text detection and recognition
  • A Watcher pod running on the CPU node that watches the bucket and triggers the OCR service automatically
  • Civo Object Store as both the input source and the JSON result destination

Dropping an image into the bucket is the only manual step in the entire pipeline. 

Prerequisites

Before starting, make sure you have the following:

Tested with: Civo CLI v1.5.2, Kubernetes v1.34.2, Docker v27.x, Python 3.11, PaddleOCR v2.7.3

Create the following folder structure:

paddle-ocr-api/
  ocr-service/
    main.py
    requirements.txt
    Dockerfile
  watcher/
    watcher.py
    requirements.txt
    Dockerfile
  kubernetes/
    ocr-deployment.yaml
    ocr-service.yaml
    watcher-deployment.yaml

Everything else gets created as part of the tutorial.

How it fits together

When you drop an image into the pending folder of your Civo Object Store bucket, the Watcher pod notices it. The Watcher runs on the CPU node, polls the pending folder on a fixed interval, and when it finds a new file, it calls the OCR service internally, passing the object key.

The OCR service fetches the image from Object Store and hands it to PaddleOCR, which runs two models back to back:

  • A detection model that locates where text sits on the page
  • A recognition model that reads it

The result gets written to the results folder in Object Store as a JSON file and returned to the Watcher. The ClusterIP service makes this internal communication work by giving the OCR service a stable hostname inside the cluster. Nothing in this pipeline is public-facing.

Results folder in Object Store

Image by Author

Everything in this pipeline runs inside Civo. The cluster, the Object Store bucket, and the network between the two services are all in the same environment, so there is nothing external to configure, no third-party storage to connect, and no traffic leaving the platform to get to the inference layer. 

Cluster sizing

This tutorial uses two node pools: a small CPU node for the Watcher pod and system workloads, and a single L40S GPU node for the OCR service at $1.29 per hour. One GPU node is enough because this pipeline runs a single always-on pod, not a parallel batch job.

Node poolCountSizePurpose

CPU

1

Small (2 vCPU / 4GB)

System pods + Watcher

GPU

1

L40S

OCR inference

Both node pools are small by design. The Watcher barely touches the CPU node, and the OCR service runs as a single always-on pod on the GPU. One node each is all this pipeline needs.

Creating the cluster

Before deploying anything, you need a Kubernetes cluster with two node pools: one CPU node for the Watcher pod and one L40S GPU node for the OCR service.

Authenticate the Civo CLI and create the cluster:

civo apikey save my-key YOUR_API_KEY_HERE
civo apikey use my-key
civo kubernetes create ocr-cluster \
--size=g4s.kube.small \
--nodes=1 \
--region=NYC1 \
--wait

If the CLI times out, run civo kubernetes ls --region=NYC1 and confirm the cluster shows ACTIVE with All Workers Up: True before moving on.

Cluster Active

Cluster Active

Save the kubeconfig so kubectl can connect to the cluster:

# Linux / macOS

civo kubernetes config ocr-cluster --region NYC1 --save --switch

# Windows

civo kubernetes config ocr-cluster --region NYC1 --save --switch --local-path $env:USERPROFILE\.kube\config

Now add the L40S GPU node pool:

civo kubernetes node-pool create ocr-cluster \
--size=an.g1.l40s.kube.x1 \
--nodes=1 \
--region=NYC1

Wait 3 minutes, then verify both nodes are Ready:

kubectl get nodes
 Nodes Ready

Nodes Ready

Installing the NVIDIA GPU Operator

Without the GPU Operator, nvidia.com/gpu: 1 in the deployment YAML is not recognized, and the OCR service pod will stay in Pending indefinitely.

helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update
helm upgrade --install gpu-operator \
-n gpu-operator --create-namespace \
nvidia/gpu-operator \
--set driver.enabled=true \
--set toolkit.enabled=false \
--set devicePlugin.enabled=true \
--set gfd.enabled=true \
--set operator.defaultRuntime=containerd \
--set validator.cuda.runtimeClassName=nvidia

Wait 5 minutes, then verify all pods are Running or Completed:

kubectl get pods -n gpu-operator
Operator Running

Operator Running

Verify the GPU is allocatable:

kubectl get nodes -o custom-columns="NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
GPU Allocatable

GPU Allocatable

The GPU node must show 1 before moving on.

Setting up Civo Object Store

The Object Store bucket is the entry point for the entire pipeline. Images go into the pending/ folder, and JSON results come back into the results/ folder. Nothing in the pipeline moves without the bucket in place first.

Create the bucket credentials and the bucket itself:

civo objectstore credential create ocr-creds --region=NYC1
civo objectstore create ocr-jobs --region=NYC1 --size=500

Verify the bucket is ready:

civo objectstore ls --region=NYC1
Bucket Ready

Get the access key:

civo objectstore show ocr-jobs --region=NYC1

Copy the Access Key from the output, then get the secret:

civo objectstore credential secret --access-key=YOUR_ACCESS_KEY --region=NYC1

Save both values somewhere safe. You will use them in the deploy step to create the Kubernetes Secret that injects the credentials into both pods at runtime. 

Now configure s3cmd to talk to the bucket:

s3cmd --configure

Fill in the following:

  • Access Key: Your bucket access key
  • Secret Key: Your bucket secret key
  • Default Region: us-east-1
  • S3 Endpoint: objectstore.nyc1.civo.com
  • DNS-style bucket: objectstore.nyc1.civo.com
  • Encryption password: Leave empty
  • Use HTTPS: Yes

When asked to test, say Yes. You should see:

s3cmd Configured

s3cmd Configured

Now, create the folder structure inside the bucket:

New-Item -Path .keep -ItemType File
s3cmd put .keep s3://ocr-jobs/pending/.keep
s3cmd put .keep s3://ocr-jobs/results/.keep
Remove-Item .keep

Upload your test images to the pending folder:

s3cmd put .\test-images\invoice.jpg s3://ocr-jobs/pending/invoice.jpg
s3cmd put .\test-images\resume.jpg s3://ocr-jobs/pending/resume.jpg
s3cmd put .\test-images\form.jpg s3://ocr-jobs/pending/form.jpg

Verify everything is in place:

s3cmd ls s3://ocr-jobs/pending/
Images Uploaded

Images Uploaded

With the bucket ready and the test images uploaded, the next step is building the two services that will process them.

Building the OCR service

The OCR service is where the actual work happens. It runs as a single FastAPI pod on the L40S GPU node, stays alive between requests, and every time the Watcher finds a new image it calls this service with the object key and waits for the result. 

The /ocr endpoint accepts the object key, fetches the image from Object Store, runs PaddleOCR, writes the result JSON to the results/ folder, and returns the output to the Watcher.

Initialization

The first thing main.py does is initialize the app and the OCR engine:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import boto3
import os
import tempfile
from paddleocr import PaddleOCR
import json
app = FastAPI()
ocr = PaddleOCR(use_angle_cls=True, lang="en")

PaddleOCR initializes once at startup, loads both models into GPU memory, and stays ready. Every request goes straight to inference 

Object Store client

Next, the service connects to Object Store using credentials from environment variables:

from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url=os.environ["OBJECT_STORE_ENDPOINT"],
aws_access_key_id=os.environ["OBJECT_STORE_ACCESS_KEY"],
aws_secret_access_key=os.environ["OBJECT_STORE_SECRET_KEY"],
config=Config(
signature_version="s3v4",
request_checksum_calculation="when_required"
)
)

Nothing is hardcoded. The Kubernetes Secret injects all four values at runtime.

The endpoint

The request model and the /ocr endpoint:

class OCRRequest(BaseModel):
object_key: str
@app.post("/ocr")
def run_ocr(request: OCRRequest):
try:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
s3.download_fileobj(BUCKET, request.object_key, tmp)
tmp_path = tmp.name
result = ocr.ocr(tmp_path, cls=True)
lines = []
for line in result[0]:
bbox, (text, confidence) = line
lines.append({
"text": text,
"bbox": bbox,
"confidence": round(confidence, 4)
})
output = {
"object_key": request.object_key,
"lines": lines
}
result_key = request.object_key.replace("pending/", "results/").replace(".png", ".json").replace(".jpg", ".json")
s3.put_object(
Bucket=BUCKET,
Key=result_key,
Body=json.dumps(output, indent=2),
ContentType="application/json"
)
return output
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

The endpoint fetches the image into a temp file, runs PaddleOCR, builds the output, writes the result JSON to the results/ folder by mirroring the input key, and returns the JSON to the Watcher.

Dependencies

ocr-service\requirements.txt:

fastapi
uvicorn
paddleocr==2.7.3
paddlepaddle-gpu==2.6.1
boto3
botocore
pydantic
numpy==1.24.3
opencv-python-headless==4.8.0.76

Expected output

When the endpoint processes an image, this is what the JSON looks like:

{
"object_key": "pending/invoice.jpg",
"lines": [
{
"text": "Invoice #1042",
"bbox": [[10, 20], [200, 20], [200, 45], [10, 45]],
"confidence": 0.98
},
{
"text": "Total excl. VAT",
"bbox": [[10, 300], [150, 300], [150, 320], [10, 320]],
"confidence": 0.97
}
]
}

text is what PaddleOCR reads, bbox is the four corner coordinates of where the text sits on the page, and confidence is how certain the model is on a scale of 0 to 1.

Building the Watcher

The Watcher does not do much, and that is the point. It runs on the CPU node, checks the pending/ folder every 10 seconds, and when it finds something new it forwards it to the OCR service and waits. It is the lightest possible piece of the pipeline. 

Initialization

watcher\watcher.py starts by setting up logging and the Object Store client:

import boto3
import os
import time
import requests
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
s3 = boto3.client(
"s3",
endpoint_url=os.environ["OBJECT_STORE_ENDPOINT"],
aws_access_key_id=os.environ["OBJECT_STORE_ACCESS_KEY"],
aws_secret_access_key=os.environ["OBJECT_STORE_SECRET_KEY"],
)
BUCKET = os.environ["OBJECT_STORE_BUCKET"]
OCR_SERVICE_URL = os.environ["OCR_SERVICE_URL"]
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 10))

OCR_SERVICE_URL points to the ClusterIP service name. Kubernetes resolves it internally so the Watcher calls http://ocr-service/ocr without ever knowing the IP. POLL_INTERVAL defaults to 10 seconds but can be changed via environment variable. 

Tracking processed files

The Watcher keeps an in-memory set of files it has already sent to the OCR service so the same image does not get processed twice:

processed = set()

Polling and processing

def list_pending():
response = s3.list_objects_v2(Bucket=BUCKET, Prefix="pending/")
if "Contents" not in response:
return []
return [obj["Key"] for obj in response["Contents"] if obj["Key"].endswith((".png", ".jpg", ".jpeg"))]
def process(object_key):
logging.info(f"Detected new file: {object_key}")
try:
response = requests.post(OCR_SERVICE_URL, json={"object_key": object_key})
response.raise_for_status()
logging.info(f"Processed {object_key} successfully")
processed.add(object_key)
except Exception as e:
logging.error(f"Failed to process {object_key}: {e}")
def watch():
logging.info("Watcher started, polling every %s seconds", POLL_INTERVAL)
while True:
files = list_pending()
for key in files:
if key not in processed:
process(key)
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
watch()

list_pending lists new image files in the pending/ folder, process calls the OCR service and marks the file as done, and watch runs the loop on repeat.

Dependencies

watcher\requirements.txt:

boto3
requests

The Watcher only needs boto3 to talk to Object Store and requests to call the OCR service. No GPU libraries, no heavy dependencies, which is why it runs on the CPU node and builds into a container under 100MB. 

Containerizing the Services

Each service gets its own Dockerfile. The OCR service needs a GPU-capable base image with CUDA and cuDNN pre-installed. The Watcher needs nothing more than a slim Python image.

OCR Service

ocr-service\Dockerfile:

FROM paddlepaddle/paddle:2.6.1-gpu-cuda11.7-cudnn8.4-trt8.4
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

The base image comes with CUDA 11.7, cuDNN 8.4, and TensorRT 8.4 pre-installed, so PaddleOCR runs on the L40S without any manual driver setup.

On first startup, PaddleOCR downloads its models at runtime, not during the build. Expect a minute or two before the pod starts accepting requests. Every restart after that uses the cached models.

Watcher

watcher\Dockerfile:

FROM python:3.11-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install -r requirements.txt
COPY watcher.py .
CMD ["python", "watcher.py"]

Alpine works here because the Watcher has no native GPU dependencies. The result is a container under 100MB compared to the OCR service image, which is around 7GB.

Build and push both images

docker build -t YOUR_REGISTRY/ocr-service:latest .\ocr-service docker push YOUR_REGISTRY/ocr-service:latest
docker build -t YOUR_REGISTRY/watcher:latest .\watcher docker push YOUR_REGISTRY/watcher:latest

Both images are on Docker Hub. The OCR service image carries the full PaddlePaddle GPU stack, and the Watcher image is a lightweight polling script under 100MB. The next step is the Kubernetes manifests that wire them together.

Deploying to Civo Kubernetes

With both images in the registry, three manifests handle the deployment: a Deployment and a ClusterIP Service for the OCR service, and a Deployment for the Watcher. Credentials go in separately via the CLI so nothing sensitive touches a file. 

Kubernetes Secret

A Kubernetes Secret injects sensitive values into pods as environment variables at runtime. Nothing sensitive lives in the deployment YAML or the container image.

Create the Secret using the credentials you saved in the Object Store setup step:

kubectl create secret generic object-store-credentials `
--from-literal=OBJECT_STORE_ENDPOINT=https://objectstore.nyc1.civo.com `
--from-literal=OBJECT_STORE_ACCESS_KEY=YOUR_ACCESS_KEY `
--from-literal=OBJECT_STORE_SECRET_KEY=YOUR_SECRET_KEY `
--from-literal=OBJECT_STORE_BUCKET=ocr-jobs

Verify the Secret was created:

kubectl get secret object-store-credentials

Expected:

NAME TYPE DATA AGE
object-store-credentials Opaque 4 1s

OCR Service Deployment

kubernetes\ocr-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: ocr-service
spec:
replicas: 1
selector:
matchLabels:
app: ocr-service
template:
metadata:
labels:
app: ocr-service
spec:
containers:
- name: ocr-service
image: YOUR_REGISTRY/ocr-service:latest
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: object-store-credentials
resources:
requests:
memory: "8Gi"
cpu: "2"
limits:
nvidia.com/gpu: 1
memory: "8Gi"
cpu: "2"
readinessProbe:
httpGet:
path: /docs
port: 8000
initialDelaySeconds: 60
periodSeconds: 10
nodeSelector:
nvidia.com/gpu.present: "true"

A few things worth noting:

  • nvidia.com/gpu: 1 tells Kubernetes this pod needs a GPU, without it the pod lands on the CPU node
  • nodeSelector pins the pod to the L40S node, the GPU Operator adds this label automatically
  • readinessProbe gives PaddleOCR 60 seconds to download its models before the pod starts accepting requests
  • envFrom pulls all credentials from the Secret so nothing is hardcoded

ClusterIP Service

A ClusterIP service gives the OCR pod a stable internal hostname so the Watcher can call it by name rather than by IP address. It has no external IP and is not reachable from outside the cluster.

kubernetes\ocr-service.yaml:

apiVersion: v1
kind: Service
metadata:
name: ocr-service
spec:
selector:
app: ocr-service
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: ClusterIP

Port 80 on the service maps to port 8000 on the container, so the Watcher calls http://ocr-service/ocr not http://ocr-service:8000/ocr.

Watcher Deployment

kubernetes\watcher-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: watcher
spec:
replicas: 1
selector:
matchLabels:
app: watcher
template:
metadata:
labels:
app: watcher
spec:
containers:
- name: watcher
image: YOUR_REGISTRY/watcher:latest
env:
- name: OCR_SERVICE_URL
value: "http://ocr-service/ocr"
envFrom:
- secretRef:
name: object-store-credentials
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
nodeSelector:
kubernetes.io/os: linux

OCR_SERVICE_URL is set to http://ocr-service/ocr. Kubernetes DNS resolves ocr-service to the correct pod IP automatically so the Watcher never needs to know the actual IP address.

Apply the manifests

Apply them in this order. The Secret needs to exist before the pods start otherwise they will fail to read the credentials on startup: 

kubectl apply -f kubernetes\ocr-deployment.yaml
kubectl apply -f kubernetes\ocr-service.yaml
kubectl apply -f kubernetes\watcher-deployment.yaml

Verify all resources are running:

kubectl get all
Services Running

Services Running 

If the OCR service pod stays in Pending run:

kubectl describe pod ocr-service-xxxxx

Check the Events section at the bottom for scheduling errors. The most common cause is the GPU Operator not finishing initialization. Give it a few more minutes and try again. 

Verify the OCR service loaded PaddleOCR successfully:

kubectl logs deployment/ocr-service
Watcher Processing

Once the Watcher logs show all three files processed, check the results folder:

s3cmd ls s3://ocr-jobs/results/
Results Ready

Download and inspect one result to verify the output:

s3cmd get s3://ocr-jobs/results/invoice.json -
JSON Output

JSON Output

Finally, verify the full bucket structure to confirm input and output are both in place:

s3cmd ls --recursive s3://ocr-jobs/
Bucket Overview

The Watcher detected all three images, sent them to the OCR service, and the results landed in the results/ folder without any manual intervention. Every line of text from every document is now structured JSON, ready for any downstream system to consume. 

If the Watcher logs don't show the expected output, here's what to check:

  • Watcher logs show no files detected: The pending/ folder is empty or the .keep placeholder is the only object. Run s3cmd ls s3://ocr-jobs/pending/ to confirm the images are there.
  • Watcher logs show a connection error calling the OCR service: The ClusterIP service isn't reachable. Run kubectl get svc and confirm ocr-service is listed. If the OCR pod isn't ready yet, the Watcher will keep retrying on the next poll.
  • OCR service pod is stuck in Pending: Almost always a GPU scheduling issue. Run kubectl describe pod ocr-service-xxxxx and check the Events section. If it says insufficient nvidia.com/gpu, the GPU Operator hasn't finished initializing. Give it a few more minutes.
  • Watcher logs show a 500 from the OCR service: The image may be corrupt or in an unsupported format. Check kubectl logs deployment/ocr-service for the full error. PaddleOCR supports PNG and JPEG, other formats will fail.
  • Results folder is empty after processing: The OCR service ran but failed to write to Object Store. Check the credentials in the Secret match what civo objectstore show returns.

Clean up

To remove everything when you're done:

kubectl delete deployment ocr-service watcher
kubectl delete service ocr-service
kubectl delete secret object-store-credentials

Then from the Civo dashboard, delete the Kubernetes cluster and the Object Store bucket. The GPU node bills by the hour, so delete the cluster as soon as you're finished to avoid ongoing charges.

Key takeaways 

PaddleOCR is the right tool when your documents are typed, printed, and reasonably clean. The output is deterministic, which matters when the pipeline feeds a database or financial system. Low resolution scans, dense multi-column layouts, and non-English documents are where it struggles, and an LLM with vision is the better call in those cases.

The most natural extension from here is PDF support: split pages into images with pdf2image, send each to /ocr, and merge the results. The Watcher needs one change to detect .pdf files in the pending/ folder.

Additional resources

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer at GoCardless

Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.

Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.

View author profile