Private Cloud OCR quickstart
This guide takes a platform engineer from an image tag to a processed document. Mathpix hands over the image in a registry you can pull from, and an install bundle: a Docker Compose stack for one host and a Helm chart for Kubernetes.
Prerequisites
| component | minimum | notes |
|---|---|---|
| GPU | 1× NVIDIA with at least OCR_WORKER_MEMORY_GB (default 5 GB) of VRAM per OCR worker | workers per GPU = VRAM / OCR_WORKER_MEMORY_GB: a 24 GB card runs 4, a 48 GB card 9, a 96 GB card 19. Throughput scales with that count. We do not publish per-model throughput figures because they depend on your documents |
| NVIDIA driver | 535 or newer (CUDA 12) | |
| disk | 40 GB for the image, plus retention | the object store holds every document's input, page images and outputs for the retention period, so it grows with pages processed × retention days |
| CPU, RAM | no published minimum | page rasterization, format conversion, Redis and the object store run on the CPU beside the GPU workers |
| Linux | Ubuntu 22.04, or any distribution with glibc 2.35+ | |
| Docker | 24 or newer, with the NVIDIA Container Toolkit | docker run --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi works |
| Docker Compose | v2.20 or newer | |
| Kubernetes, instead of Docker | Helm 3 or newer, the NVIDIA device plugin, a StorageClass for persistent volumes | one pco pod per GPU |
Network. Outbound HTTPS to global.gateway.mathpix.com for the license check, licensed
builds only; an airgapped build makes no outbound connection. Inbound to the API port (default
8080) from your clients. Outbound to your bucket if you use your own, and to your receiver if you
use webhooks.
From Mathpix. Pull access to the registry repository holding your image, and the tag. The
image is built for your organization: it carries your license credential and your contract's
modes (licensed or airgapped, metered or unmetered), so it is not interchangeable with
another customer's. GET /pco/v1/status shows which modes you have.
1. Install on one host with Docker
The bundle has three files: docker-compose.yml, .env.example and seaweedfs-s3.json. You
edit .env and seaweedfs-s3.json; the compose file is never edited.
docker login REGISTRY # with the credentials Mathpix gave you
cp .env.example .env # set PCO_IMAGE, PCO_DEPLOYMENT_ID, the two storage keys
sed -i 's/change-me-access-key/<same key as .env>/; s/change-me-secret-key/<same secret as .env>/' seaweedfs-s3.json
docker compose up -d --wait # first start: a few minutes while the models load
curl -s localhost:8080/health
.env needs four values:
| variable | meaning |
|---|---|
PCO_IMAGE | the image tag from Mathpix |
PCO_DEPLOYMENT_ID | the stable name of this installation, e.g. example-prod-1. Mathpix records license check-ins and usage under it. Keep it constant across upgrades and Redis rebuilds; a new id looks like a second installation |
PCO_STORAGE_ACCESS_KEY, PCO_STORAGE_SECRET_KEY | credentials for the included object store; set identically in seaweedfs-s3.json |
Your own Redis or bucket. The included redis and storage containers are Compose profiles
switched by COMPOSE_PROFILES in .env. Set the connection variables and drop the matching
profile; the container is not created, and the compose file stays untouched:
# both included (the default in .env.example)
COMPOSE_PROFILES=included-redis,included-storage
# own Redis, included storage
COMPOSE_PROFILES=included-storage
PCO_REDIS_URL=redis://redis.internal:6379/0
# own bucket, included Redis
COMPOSE_PROFILES=included-redis
PCO_STORAGE_BUCKET=s3://example-pco/
PCO_STORAGE_ENDPOINT=https://s3.amazonaws.com # keys unset: the instance role
# both your own: no profile, and both sets of connection variables
COMPOSE_PROFILES=
PCO_REDIS_URL=redis://redis.internal:6379/0
PCO_STORAGE_BUCKET=s3://example-pco/
PCO_STORAGE_ENDPOINT=https://s3.amazonaws.com
PCO_STORAGE_ACCESS_KEY=... # or unset both keys for the instance role
PCO_STORAGE_SECRET_KEY=...
The bucket URI's form picks the provider. Google Cloud Storage and Azure Blob take no endpoint,
and the variable must still be present and empty: when it is absent from .env, Compose falls
back to the included store's address, which the GCS and Azure clients would read as an emulator.
# Google Cloud Storage: Application Default Credentials, here the host VM's service account
COMPOSE_PROFILES=included-redis
PCO_STORAGE_BUCKET=gs://example-pco/
PCO_STORAGE_ENDPOINT=
PCO_STORAGE_ACCESS_KEY=
PCO_STORAGE_SECRET_KEY=
# Azure Blob: the account key, or both keys empty for the host VM's managed identity
COMPOSE_PROFILES=included-redis
PCO_STORAGE_BUCKET=https://example.blob.core.windows.net/pco/
PCO_STORAGE_ENDPOINT=
PCO_STORAGE_ACCESS_KEY=<account key>
PCO_STORAGE_SECRET_KEY=
Credentials come from where the container runs: on Compose, the host VM's identity (an EC2
instance role, a GCE service account, an Azure managed identity) or the S3 keys and Azure account
key above; on Kubernetes, the node's identity in the same way. The chart does not yet attach a
per-pod identity (GKE Workload Identity, Azure Workload Identity), so grant the bucket to the
node pool's identity or use keys. Run Redis with persistence (appendonly yes, or ElastiCache
and Azure Cache with persistence on): the deployment recovers usage counters from the bucket
after a Redis loss, but documents queued in Redis at that moment are lost.
2. Install on Kubernetes with Helm
The chart installs everything in one command: a pco Deployment with one GPU per pod, Redis and
SeaweedFS as StatefulSets on persistent volumes, the Service, and the pull secret for your
registry credentials.
cat > pco-values.yaml <<'VALUES'
image: REGISTRY/mathpix-pco:TAG
registry: {server: REGISTRY, username: USER, password: TOKEN}
deploymentId: example-prod-1
replicas: 2 # one pod per GPU
storage: {accessKey: <any random string>, secretKey: <any random string>}
VALUES
helm install pco ./helm/mathpix-pco -n pco --create-namespace -f pco-values.yaml
kubectl -n pco rollout status deployment/pco-pco # a few minutes while the models load
Your own Redis or bucket: redis.enabled: false with redis.url, or storage.enabled: false
with storage.bucket and, for S3, storage.endpoint (leave the keys empty to use the node's IAM
role). GPU nodes with taints: nodeSelector and tolerations. Clusters that expose the NVIDIA
runtime as a RuntimeClass, such as k3s and RKE2: runtimeClassName: nvidia; EKS, GKE and AKS with
the NVIDIA device plugin need nothing. Reaching the API from outside the cluster:
service.type: LoadBalancer, or your Ingress in front of the pco-pco Service. The chart refuses
to render when a required value is missing and says which. helm upgrade replaces pods one at a
time, old pod first, so it never needs a spare GPU; helm uninstall keeps the Redis and storage
volumes, delete their PersistentVolumeClaims yourself when you mean to. Every value is documented
in the chart's values.yaml.
3. Process a document
Health is {"status": "ok", ...} when Redis and storage answer and at least one GPU worker is
alive. Then:
curl -sF file=@sample.pdf localhost:8080/v3/pdf # {"pdf_id": "...", "status": "processing"}
curl -s localhost:8080/v3/pdf/<pdf_id> # status, num_pages, num_pages_completed
curl -s localhost:8080/v3/pdf/<pdf_id>.mmd # the Mathpix Markdown
curl -sF file=@sample.pdf -F 'options_json={"conversion_formats":{"docx":true}}' localhost:8080/v3/pdf
curl -s -o out.docx localhost:8080/v3/pdf/<pdf_id>.docx # 202 with Retry-After while converting
The request and response shapes are the hosted API's: Process Documents
and Process Images apply, minus app_id and app_key.
4. Or use the pco tool
pco config set endpoint http://HOST:8080
pco status # health, versions, workers, license
pco convert paper.pdf # paper.mmd and paper.lines.json beside it
pco convert ./scans/ --formats md,docx # a folder: several files at a time, one progress line per file
pco convert s3://example-docs/scans/ --formats md # a folder in your bucket: a job on the deployment, watched to the end
pco convert paper.pdf --options-json '{"page_ranges": "1-5", "include_hyperlinks": true}' # any /v3/pdf option
pco text equation.png # one image through POST /v3/text, the JSON body printed
--detach returns once the deployment has the work; pco convert --resume RUN_ID fetches local
results later. Ctrl-C pauses and asks before stopping. --dry-run sends nothing. --options-json
passes any /v3/pdf option through unchanged, and on pco text any /v3/text option. The tool
will be released as open source with its own documentation.
Configuration reference
Everything the deployment reads from the environment. Nothing else is configurable at run time; the customer name, license mode, metering mode and credential are baked into your image.
| variable | required | meaning |
|---|---|---|
PCO_REDIS_URL | yes | redis://host:6379/0 |
PCO_STORAGE_BUCKET | yes | bucket root URI; its form picks the provider (above) |
PCO_STORAGE_ENDPOINT | S3 only; present and empty for GCS and Azure | http://storage:8333, https://s3.amazonaws.com, your gateway. On Compose an absent variable means the included store |
PCO_STORAGE_ACCESS_KEY, PCO_STORAGE_SECRET_KEY | S3: optional together; Azure: account key in the first | unset on S3 means the default credential chain (instance role) |
PCO_DEPLOYMENT_ID | yes | the stable installation name; the compose file and the chart refuse to start without it |
OCR_WORKER_MEMORY_GB | no, default 5 | GPU memory per OCR worker; workers per GPU = VRAM / this |
PCO_LOG_FORMAT | no, default text | json for a logging tool: one object per line with the fields listed under logs |
PCO_LOG_LEVEL | no, default INFO | DEBUG adds the OCR stack's per-page bookkeeping |
PCO_LOG_SINK | no | a copy of every line from the container, for a host without a log agent: syslog+udp://, syslog+tcp://, syslog+tls://host:port, or cloudwatch://GROUP?region=...; see shipping logs |
A missing or contradictory variable stops the container at startup with the variable named in the log, rather than at the first request.
If you lose the deployment id (a rebuilt host with no copy of .env): while the service
runs, GET /pco/v1/status shows it; the bucket holds a copy at pco-meta/deployment_id; and for
a licensed or metered build Mathpix support can read it back to you from your organization
name. An airgapped, unmetered build sends nothing to Mathpix, so for it the bucket copy and
your usage exports are the record.
Where to go next
- Operations: what to monitor, usage and license, upgrades, retention, troubleshooting.
- Batch jobs: a folder in your bucket in, outputs beside the inputs out.