Skip to content

Blog

Ship MinerU on RunPod logs to Axiom via OpenTelemetry

Last Updated: 2026-05-26

If you’re running a serverless worker on RunPod, you can’t ssh in to read logs and you can’t run a sidecar agent — the worker scales to zero between jobs. You need to ship logs and metrics off the box during the request lifetime, on the worker’s own time. The mineru-runpod template includes an OpenTelemetry exporter for exactly this, and Axiom is the sink I picked for my own deployment.

This post is the exact env-var layout. If you use a different OTLP backend (Honeycomb, Grafana, Datadog, Jaeger, your own OpenTelemetry Collector), the observability guide covers the vendor-neutral setup; come back here only for the Axiom-specific values.

Setup time from a fresh Axiom account to logs flowing: ~10 minutes, dominated by waiting for the worker’s next cold start.

Why use Axiom for serverless worker observability?

Section titled “Why use Axiom for serverless worker observability?”

Axiom ingests OTLP/HTTP directly, charges per event rather than per host, and has no agent to install — which matters when your workers scale to zero between jobs. For a mineru-runpod deployment processing a few hundred PDFs a day, the events fit inside Axiom’s free tier, and the metrics dataset is queryable via APL.

Three concrete reasons it fits this workload:

  • OTLP-native ingest. No collector to run inside the container, no daemonset, no Fluentbit config. The mineru-runpod worker calls Axiom’s regional edge endpoint (https://eu-central-1.aws.edge.axiom.co/v1/logs or the US variant) directly via the OpenTelemetry Python SDK. The serverless model rules out anything that needs a long-running sidecar, so “the exporter IS the integration” is the only model that works.
  • Per-event pricing instead of per-host. A worker serving 500 PDFs a day emits roughly 5,000 log records and 2,500 spans. That fits comfortably inside Axiom’s free 0.5 GB/month tier. Per-host pricing models (the Datadog and NewRelic shape) penalize the ephemeral-worker pattern.
  • APL reads like SPL. Axiom Processing Language sits between Splunk SPL and KQL ergonomically. Filter by attribute, group by backend, drill into a span: the queries you actually run during an incident are easy in APL. No tutorial needed if you’ve used either.

I have no affiliation with Axiom. They’re the backend I picked for my own deployment after looking at Honeycomb, Grafana Cloud, and self-hosted Jaeger.

How do I create Axiom datasets for OpenTelemetry?

Section titled “How do I create Axiom datasets for OpenTelemetry?”

Create two datasets in the Axiom UI: one Events dataset (holds both traces and logs) and one Metrics dataset. Those are the only two dataset types Axiom exposes in the UI; metrics are kept separate because they use a different storage format under the hood.

  1. Sign in to Axiom and go to DatasetsNew dataset.
  2. Create mineru-events with Events type. This holds traces and logs together.
  3. Create mineru-metrics with Metrics type.

Names are arbitrary — substitute whatever fits your naming convention. I prefix everything with the service name so multiple endpoints (staging, prod, experiments) don’t collide in queries.

How do I generate an Axiom API token for OTLP ingest?

Section titled “How do I generate an Axiom API token for OTLP ingest?”

In the Axiom UI, go to Settings → API tokens → Generate new token. Use an Advanced API token (prefix xaat-) and explicitly grant the Ingest scope on both datasets you just created. Forgetting the scope on the metrics dataset is one of the common failure modes — it surfaces as 403 Forbidden in the worker logs while events ingest works fine. Copy the resulting xaat- prefixed string and paste it into your RunPod endpoint config in the next section.

Treat the token like any production secret: paste it only into RunPod’s Environment Variables UI (which encrypts at rest), never check it into git, and rotate when employees with access leave.

If your Axiom workspace is in the EU region, the management API lives at https://api.eu.axiom.co (US is https://api.axiom.co). This is the host you query for token CRUD and REST queries, not the OTLP ingest URL — that’s a separate edge-deployment hostname documented in the env-var section below.

What environment variables ship RunPod worker telemetry to Axiom?

Section titled “What environment variables ship RunPod worker telemetry to Axiom?”

Set four environment variables on your RunPod endpoint. OTEL_EXPORTER_OTLP_HEADERS covers both traces and logs (they share the Events dataset); OTEL_EXPORTER_OTLP_METRICS_HEADERS overrides for metrics only because Axiom uses a different header for its metrics ingest.

Paste this into your endpoint’s Environment Variables section in the RunPod dashboard, substituting your token and dataset names:

Terminal window
OTEL_EXPORTER_OTLP_ENDPOINT=https://eu-central-1.aws.edge.axiom.co
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer xaat-YOUR-TOKEN,x-axiom-dataset=mineru-events
OTEL_EXPORTER_OTLP_METRICS_HEADERS=Authorization=Bearer xaat-YOUR-TOKEN,x-axiom-metrics-dataset=mineru-metrics
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

The endpoint URL is your Axiom edge deployment, NOT api.axiom.co / api.eu.axiom.co. This is the single biggest gotcha and the one that cost me hours when I first set this up. The api.* hosts are for management API (token creation, queries via REST). OTLP ingest goes to your workspace’s edge deployment hostname. As of writing, the two are:

  • US East 1: https://us-east-1.aws.edge.axiom.co
  • EU Central 1: https://eu-central-1.aws.edge.axiom.co

Use the one that matches the region you picked when creating the Axiom workspace. The current full list lives at Axiom’s edge deployments doc. If you send OTLP to api.{eu.}axiom.co, Axiom returns 400 mismatched region or 403 forbidden depending on path — the OTel SDK logs only the HTTP status code, so you’ll see Failed to export ... code: 400 (or 403) with no clue why. Axiom support flagged this for me after I’d been chasing 403s for an hour against the wrong host.

How the SDK routes each signal:

  • Traces + logs → use the generic OTEL_EXPORTER_OTLP_HEADERS, so spans and log records both land in the mineru-events dataset.
  • MetricsOTEL_EXPORTER_OTLP_METRICS_HEADERS overrides for metrics only, with the distinct x-axiom-metrics-dataset header that Axiom’s metrics ingest requires.

The dataset names in the two *-dataset headers must exactly match the names you created in step 1, and your API token must have ingest scope on both. This is the #1 source of “everything is configured but nothing shows up” — mineru-events and mineru-metrics above are example names, not magic strings. If you named your datasets differently, update the headers to match. Mismatches surface as 404 Not Found (wrong dataset name) or 403 Forbidden (token lacks ingest scope on that dataset). Both fail silently from the caller’s side; the only signal is in the worker’s stdout, where the OTel SDK logs each retry with the HTTP status code.

Three details that trip people up:

Base URL, not the full path. Set the endpoint to the edge-deployment root, e.g. https://eu-central-1.aws.edge.axiom.co, NOT https://eu-central-1.aws.edge.axiom.co/v1/traces. The OpenTelemetry Python SDK appends /v1/traces, /v1/logs, /v1/metrics per signal automatically. If you set the full path, the SDK double-appends and Axiom returns 404.

Header case differs by signal. Events ingest uses x-axiom-dataset. Metrics ingest uses x-axiom-metrics-dataset (different header name, with -metrics- in it). Copying the events headers into OTEL_EXPORTER_OTLP_METRICS_HEADERS as-is sends metrics to the events dataset and Axiom’s metrics view stays empty.

Protocol must be protobuf for metrics. Axiom’s metrics ingest accepts only protobuf, not JSON. The http/protobuf value above is the default the SDK ships with; don’t override it to http/json thinking it’s the safer choice. JSON works for logs and traces but quietly drops metrics.

Save the variables, redeploy any active workers (or wait for the next cold start), and Axiom should see records within ~60 seconds of the first request that hits a warm worker. The metric reader flushes every 10 s; traces and logs flush every 500 ms.

How do I verify OpenTelemetry data is reaching Axiom?

Section titled “How do I verify OpenTelemetry data is reaching Axiom?”

Send one request to the worker, then check three views in the Axiom UI: Stream and Traces on the events dataset, and Metrics on the metrics dataset.

In the Axiom UI:

  • Stream view → set dataset = mineru-events → you should see JSON log records flowing as soon as the worker handles a request. Each carries service.name=mineru-runpod, runpod.endpoint_id=<your-endpoint>, and job_id for correlation.
  • Traces view → on the same mineru-events dataset → one trace per RunPod job. The root span is mineru.job. Its children are mineru.fetch_input, mineru.parse, and mineru.package. The mineru.warmup span shows up once per worker boot.
  • Metrics view → set dataset = mineru-metrics → after the first 10-second flush you should see mineru.jobs.total, mineru.job.duration, the GPU memory gauges, and the rest of the metric catalog.

If nothing arrives in any view, open the worker’s stdout (RunPod dashboard → Logs) and grep for Failed to export. The OTel SDK logs each retry with the HTTP status code:

  • Failed to export ... code: 404, reason: Not Found — the dataset name in one of the *-dataset headers doesn’t exist in your Axiom workspace. Rename the dataset or update the env var so they match.
  • Failed to export ... code: 403, reason: Forbidden — the dataset exists but your API token doesn’t have ingest scope on it. Open the token in Settings → API tokens, add Ingest scope on the dataset, and update the secret in RunPod.
  • Failed to export ... code: 403, reason: Forbiddenthe #1 cause is using api.axiom.co or api.eu.axiom.co as the endpoint instead of the edge-deployment URL. Confirm by running curl -v POST https://api.eu.axiom.co/v1/traces -H "Authorization: Bearer xaat-..." -H "x-axiom-dataset: <yours>" -H "Content-Type: application/x-protobuf" --data-binary "" against your endpoint — if you get 403 there but 422 against https://eu-central-1.aws.edge.axiom.co/v1/traces (or the US edge variant), the URL is the issue. Other 403 causes: token genuinely lacks Ingest scope on the dataset.
  • Failed to export ... code: 400, reason: Bad Request — the dataset resolves and auth works, but the payload is being rejected. Causes: region mismatch (workspace is EU but you’re hitting us-east-1.aws.edge.axiom.co, or vice versa — Axiom returns mismatched region in the body), wrong header name on metrics (use x-axiom-metrics-dataset, not x-axiom-dataset), or the dataset was created of the wrong type for the signal.
  • Failed to export ... code: 401, reason: Unauthorized — the API token is wrong or expired. Generate a fresh token in Settings → API tokens and update the env var.
  • No Failed to export lines AND no [mineru-telemetry] init failed either — OTEL_EXPORTER_OTLP_ENDPOINT is empty or the worker hasn’t cold-started since you set it. RunPod env-var changes only take effect on the next cold start; warm workers keep the previous values.

Which APL queries help debug a mineru-runpod worker?

Section titled “Which APL queries help debug a mineru-runpod worker?”

APL queries answer the three operational questions that come up most: which errors fired in the last hour, which parses are slowest, and how throughput breaks down by backend or endpoint. All three queries hit the events dataset, where both traces and logs land. The templates below are starting points — adjust attribute paths to match how your Axiom workspace unrolls OTLP resource and span attributes.

Recent errors, grouped by error type:

['mineru-events']
| where ['service.name'] == "mineru-runpod"
| where ['severity_text'] == "ERROR"
| where _time > ago(1h)
| summarize count() by tostring(['attributes.error_type'])
| sort by count_ desc

Slowest parses in the last hour:

['mineru-events']
| where ['name'] == "mineru.parse"
| where _time > ago(1h)
| project _time, duration = ['duration'], backend = ['attributes.mineru.backend'], input_format = ['attributes.mineru.input_format']
| sort by duration desc
| take 20

Throughput by endpoint over time:

['mineru-events']
| where ['name'] == "mineru.job"
| where _time > ago(24h)
| summarize jobs = count() by endpoint_id = tostring(['resource.runpod.endpoint_id']), bin(_time, 5m)
| render timechart

Attribute paths in APL depend on how Axiom unrolls OTLP records — ['resource.runpod.endpoint_id'] works in my workspace but yours may need ['runpod.endpoint_id'] directly. Run a quick | take 5 | project * against the dataset first to see the actual field names your workspace produces.

Does enabling OpenTelemetry slow down cold starts?

Section titled “Does enabling OpenTelemetry slow down cold starts?”

Enabling OTel adds roughly 200–500 ms to the first-ever cold start (one-time SDK init plus DNS resolution for the OTLP endpoint). Subsequent FlashBoot snapshot restores on the same host inherit the warm state, so the cost amortizes per host, not per request. For a parse that takes 5–30 seconds wall-clock, the overhead is invisible.

The numbers from my own deployment: on an RTX 4090 with vlm-auto-engine, the bare cold start is ~110 s (image pull + vLLM init + model load + warmup parse). OTel init adds 200–500 ms on top of that. On a FlashBoot-restored boot, the same overhead is 0 — the snapshot captures the initialized SDK along with the rest of the process state. See the FlashBoot mechanism investigation for how the snapshot path actually works.

If you’re cost-sensitive about cold starts and don’t need observability on every deployment, leave OTEL_EXPORTER_OTLP_ENDPOINT unset. The mineru-runpod worker skips the OTel SDK import entirely when that variable is empty — zero overhead, zero behavior change. Flip it on for the endpoints where you actually want the visibility (production, staging) and leave it off for experimentation runs.

Three real limitations: Axiom’s free tier caps ingest at 0.5 GB/month and 30-day retention; the GPU gauges emit one time series per device label per metric and cardinality adds up; and OTLP/HTTP export adds modest latency on cold starts (200–500 ms). None of these are blockers for a small or mid-volume serverless deployment, but they’re the things to watch as volume grows.

Free-tier ceilings. Axiom’s free plan is generous (0.5 GB ingest/month, 30-day retention) but you can blow through it with a chatty worker. A debug-logging worker processing 1,000 PDFs/day at ~30 KB of logs per parse hits ~900 MB/month — past the cap. Either keep the log level at info (the default) or move to Axiom’s paid tier (currently $25/month for 5 GB ingest).

Metric cardinality. The GPU gauges (mineru.gpu.memory_used_bytes, mineru.gpu.utilization_percent) emit one time series per device label per gauge per worker. A multi-GPU worker times multiple worker instances times four GPU metrics multiplies fast. Axiom’s metrics pricing is per-event rather than per-series, so this is a “watch the bill” concern rather than a hard limit. If you scale to dozens of concurrent workers, drop the device label or sample less frequently.

Cold-start latency. OTel init adds 200–500 ms on first-ever boot. For most workloads this is dominated by the existing ~110 s of vLLM init, so it doesn’t matter. If you’re optimizing the cold start specifically (chatbot-style low-latency workloads, for example), benchmark with and without OTel before committing.

Logs are mirrored, not exclusive. The worker still writes stdout JSON to RunPod’s dashboard regardless of OTel. That’s deliberate: RunPod’s UI remains a working fallback when the OTel pipeline misbehaves. The cost is paying twice for log storage if you care about long retention in both places. Most teams don’t, and the duplication is the price of the dashboard fallback.

Does Axiom support OpenTelemetry natively?

Section titled “Does Axiom support OpenTelemetry natively?”

Yes. Axiom ingests OTLP/HTTP traces, logs, and metrics on /v1/traces, /v1/logs, /v1/metrics paths — but the base hostname is your region’s edge deployment, not api.axiom.co. The two as of writing are https://us-east-1.aws.edge.axiom.co and https://eu-central-1.aws.edge.axiom.co (full list at Axiom’s edge deployments doc). The OpenTelemetry Python SDK in the mineru-runpod worker speaks this directly with no Collector or agent in between. See Axiom’s OpenTelemetry docs for the full list of supported signals and headers.

Why aren’t my metrics showing up in Axiom?

Section titled “Why aren’t my metrics showing up in Axiom?”

Three common causes. First: OTEL_EXPORTER_OTLP_METRICS_HEADERS uses the wrong header key. Axiom needs x-axiom-metrics-dataset (lowercase, with -metrics-dataset), distinct from the x-axiom-dataset header used for logs and traces. Second: the API token doesn’t have ingest scope on the metrics dataset — common when you create the token with only the logs dataset selected, then later add the metrics one. Surfaces as 403 Forbidden in the worker stdout. Third: OTEL_EXPORTER_OTLP_PROTOCOL is set to http/json and Axiom’s metrics endpoint accepts only protobuf.

What’s the cheapest way to observe a RunPod serverless worker?

Section titled “What’s the cheapest way to observe a RunPod serverless worker?”

For ≤1,000 jobs/day with structured logs at info level, Axiom’s free tier (0.5 GB/month, 30-day retention) is the cheapest path with real query power. The OTel SDK is already in the mineru-runpod image; setup is four env vars on the endpoint. RunPod’s own log dashboard is free but lacks query language, metrics, and traces — fine for triage, not for SLO tracking.

Can I send traces and logs to the same Axiom dataset?

Section titled “Can I send traces and logs to the same Axiom dataset?”

Yes — that’s the standard setup. Axiom exposes only two dataset types in the UI (Events and Metrics), and the Events type holds both traces and logs. The configuration above routes traces + logs to one events dataset via OTEL_EXPORTER_OTLP_HEADERS, and metrics to a separate metrics dataset via OTEL_EXPORTER_OTLP_METRICS_HEADERS. No per-signal traces override is needed.

How much does OTLP/HTTP export add to cold start time?

Section titled “How much does OTLP/HTTP export add to cold start time?”

200–500 ms on the first-ever cold start of a worker on a host. That’s one-time SDK init and DNS resolution for the OTLP endpoint. Subsequent FlashBoot snapshot restores on the same host pay zero overhead — the initialized SDK is captured in the process snapshot. On a baseline 110 s cold start (vLLM + model load + warmup), the OTel cost is invisible.

Why isn’t api.axiom.co the OTLP endpoint?

Section titled “Why isn’t api.axiom.co the OTLP endpoint?”

Because Axiom splits two concerns onto two hostnames: api.{eu.}axiom.co is the management API (token CRUD, REST queries, dashboards), while OTLP ingest goes to the edge deployment hostname for your workspace’s region. The split isn’t obvious from the OpenTelemetry side because most other backends expose ingest on the same hostname as the management API. Axiom’s own OpenTelemetry guide and edge deployments doc document the edge URLs, but it’s easy to miss if you start from a generic OTel tutorial.

Does enabling OpenTelemetry require an OpenTelemetry Collector?

Section titled “Does enabling OpenTelemetry require an OpenTelemetry Collector?”

No. The mineru-runpod worker uses the OpenTelemetry Python SDK with the OTLP/HTTP exporter, talking directly to Axiom’s ingest endpoint. A Collector is useful when you want to fan out to multiple backends, apply sampling rules, or buffer locally — none of which apply to a single-sink serverless worker shipping into Axiom.

The four env vars above are the only Axiom-specific configuration in this whole setup. Swap the URL and headers and the mineru-runpod worker ships the same logs, traces, and metrics to anything that speaks OTLP/HTTP — Honeycomb, Grafana Cloud, Datadog’s OTLP intake, your own OpenTelemetry Collector. Most other backends don’t even need the metrics-headers override that Axiom requires — a single OTEL_EXPORTER_OTLP_HEADERS value covers all three signals. The observability guide covers the vendor-neutral env-var layout and the metric catalog. Different backends, same template.

If this saved you time, the easiest way to say thanks is signing up for RunPod through this link. Star the repo on GitHub for updates.


Disclosure: RunPod links in this post use a referral code that credits me at no cost to you. The post would read the same without it.

RunPod 20 MB Response Cap: Fix NoneType with Cloudflare R2

Last Updated: 2026-05-26

If your RunPod serverless worker logs say done but your client raises unexpected handler return type: <class 'NoneType'>, you’ve hit RunPod’s bidirectional 20 MB payload cap on /runsync. The handler succeeded. The gateway dropped the response on the way back because the payload was too large.

The fix is two steps. Set return: "s3" on the job, and configure four env vars on the endpoint pointing at a Cloudflare R2 bucket. The worker uploads the result to R2 and returns a small presigned URL. Your client downloads from R2 directly. No gateway cap in the path.

I hit this on an 82-page Cyrillic fiscal report (30 MB input, ~25 MB output with embedded images) running my open-source mineru-runpod template. Two retries via return: "inline" and return: "tarball_b64" failed the same way. R2 mode worked first try. The rest of this post is the symptom, the env-var recipe, the cost comparison vs S3, and a few gotchas worth knowing.

Why does my RunPod worker return NoneType after a successful parse?

Section titled “Why does my RunPod worker return NoneType after a successful parse?”

The worker handler completed and returned a valid dict. RunPod’s runtime then tried to POST that result back to RunPod’s API via /job-done, and the API returned HTTP 400 because the payload exceeded ~20 MB. The result was discarded. The SDK saw no output, returned None to the client, and the client wrapper raised the NoneType error.

The worker logs make the chain explicit:

[mineru-worker] done: elapsed=91.77s phase_ms={'fetch_input': 972, 'mineru_parse': 90789, 'package': 66}
{"requestId": "sync-fdcd03cd-...", "message": "Failed to return job results. | 400, message='Bad Request',
url='https://api.runpod.ai/v2/<endpoint>/job-done/<worker>/sync-fdcd03cd-...?gpu=NVIDIA+RTX+A5000&isStream=false'"}

The first line shows the handler finished cleanly: 82 pages parsed in 91.8 s on the worker (this test ran on A5000; on the current 4090 default the warm parse is 2–3× faster). The second line shows the gateway rejecting the result. The handler already returned and never knows the rejection happened. The SDK sees the discarded result and returns None to your code.

If you see this NoneType error on a small doc, the diagnosis is different (worker OOM, crash, timeout). On a multi-page parse that the worker logs as done, the answer is almost always the 20 MB cap.

What is RunPod’s /runsync response payload limit?

Section titled “What is RunPod’s /runsync response payload limit?”

RunPod’s /runsync gateway caps payloads at roughly 20 MB in both directions. The request cap affects file_b64 inline uploads. The response cap affects what the worker can return. Both are independent of execution time and memory budget. A fast, successful parse can hit the response cap simply by producing a large output.

Direction Limit What triggers it
Request → gateway → worker ~20 MB file_b64 inline transport for large PDFs
Worker → gateway → client ~20 MB Multi-page parse outputs with embedded images

The request cap is in RunPod’s docs and widely discussed. The response cap is mentioned only in passing. I found three open issues on the runpod-workers repos where other users hit the same symptom and didn’t realise what it was, so this post is partly to make that searchable.

Practical threshold for mineru-runpod: pure-text PDFs are fine for longer. Image-heavy PDFs with embedded raster output hit the response cap around 50–80 pages on inline or tarball_b64 transport.

Does return: "tarball_b64" get around the 20 MB cap?

Section titled “Does return: "tarball_b64" get around the 20 MB cap?”

No. return: "tarball_b64" gzips the output into a single .tar.gz before base64-encoding it. Gzip compresses the JSON and Markdown text well, but the page images inside the tarball are already raster bytes (PNG, JPEG) and barely compress further. Multi-page parses with embedded images keep the tarball over 20 MB.

I confirmed this on the same 82-page PDF. Same 400 from /job-done. Same NoneType in the client. Both inline and tarball_b64 route through the gateway response, so both inherit the cap. Only return: "s3" avoids it because the worker uploads out-of-band.

How do I configure Cloudflare R2 to bypass the RunPod response cap?

Section titled “How do I configure Cloudflare R2 to bypass the RunPod response cap?”

Set return: "s3" in the job input, then add four env vars on the RunPod endpoint pointing at a Cloudflare R2 bucket. The worker uploads the gzipped tarball directly to R2 and returns a small presigned URL (~1 h TTL). Your client downloads from R2.

The job input changes one field:

{
"input": {
"file_url": "https://example.com/big.pdf",
"return": "s3"
}
}

The four env vars go on the endpoint (not the template — they’re secrets):

Env var Cloudflare R2 value
BUCKET_ENDPOINT_URL https://<account-id>.r2.cloudflarestorage.com
BUCKET_NAME your bucket name
BUCKET_ACCESS_KEY_ID R2 API token access key
BUCKET_SECRET_ACCESS_KEY R2 API token secret
BUCKET_REGION (optional) auto

You generate the access key pair in the Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API Token → Object Read & Write scoped to the bucket. The worker auto-restarts when you save endpoint env vars in RunPod. Test with one small doc before sending production traffic.

Why pick Cloudflare R2 over AWS S3 for RunPod output storage?

Section titled “Why pick Cloudflare R2 over AWS S3 for RunPod output storage?”

R2 has zero egress fees, a 10 GB free storage tier, 1M Class A ops and 10M Class B ops per month free, and is fully S3-compatible. AWS S3 charges egress at roughly $0.085/GB plus storage at $0.023/GB/month. For a RunPod pipeline doing dozens of GB of I/O per month, R2’s bill stays near zero while S3 lands in the $5–$15 range.

A back-of-envelope month for the workload I tested:

  • 1,000 multi-page parses, average output 8 MB → 8 GB stored then deleted
  • 1,000 worker→bucket uploads + 1,000 client→bucket downloads = 2,000 ops
  • Storage: free (under 10 GB). Egress: free (R2 doesn’t bill egress). Ops: free (well under 1M Class A).

Same workload on S3: ~$0.18 storage + ~$0.68 egress + per-request fees, maybe $1–$3 total. Cheap but R2’s $0 is cheaper.

S3 still makes sense if you’re already deep in AWS, if you need IAM-controlled access patterns, or if RunPod workers and your AWS region are colocated tightly enough that egress doesn’t apply. For everyone else and especially for solo / indie deploys, R2 is the right default. See R2 pricing for current rates.

What does the parse flow look like end-to-end with return: "s3"?

Section titled “What does the parse flow look like end-to-end with return: "s3"?”

The worker fetches the input PDF, runs MinerU, gzips the outputs into a tarball, uploads to R2 via the configured BUCKET_* env vars, and returns a small JSON response with tarball_url, tarball_url_expires_in (3600 s), and bucket_key. Your client follows the URL and extracts the tarball locally. No payload ever crosses RunPod’s 20 MB-capped response path.

Concrete numbers from the 82-page test (on A5000; current default is 4090):

result = client.parse_document(
file_url="https://pub-....r2.dev/report.pdf",
backend="vlm-auto-engine",
return_format="s3",
)
# result["tarball_url"] -> presigned R2 URL, valid ~1 h
# result["tarball_url_expires_in"] -> 3600
# result["bucket_key"] -> "report-<hash>.tar.gz"
client.save_s3_tarball(result, "./out/")
# downloads + extracts -> out/report.md, out/report_content_list_v2.json, out/images/, ...

End-to-end wall-clock: 211 s for an 82-page doc on a cold worker. Breakdown: ~112 s before MinerU started parsing (worker boot + warmup), ~92 s warm parsing (1.1 s/page on A5000), ~11 s gzip and upload to R2 (the package phase). The extracted output: 313 KB Markdown plus structured JSON plus per-page images. Roughly 3.5 minutes for a document that previously couldn’t return its output at all.

The cold-start portion is a separate concern from the response cap. The FlashBoot mechanism investigation covers why the ~112 s exists, how the boot-time warmup interacts with RunPod’s snapshot system, and when subsequent cold starts are much faster.

What should I watch out for with the R2 bridge?

Section titled “What should I watch out for with the R2 bridge?”

Four things the docs don’t say loudly. The presigned URL TTL is 60 minutes. R2 doesn’t auto-clean uploaded objects. One bucket can serve input and output. The 20 MB cap applies to /run (async) too, not just /runsync.

  • Presigned URL TTL is 60 minutes. If your client is slow to download (e.g. a job-queue worker that picks up results minutes later), bump _S3_PRESIGN_TTL_SECONDS in the handler. Don’t rely on the default in long-tail flows.
  • R2 doesn’t auto-clean uploaded objects. Add an R2 lifecycle rule (e.g. delete after 7 days) so your output bucket doesn’t grow forever.
  • One R2 bucket can serve input and output. Upload your PDFs to R2 ahead of time, pass file_url pointing at the R2 public dev URL, and the worker writes outputs to the same bucket at the root. Add BUCKET_PREFIX env var if you want outputs in a subdirectory.
  • The 20 MB cap applies to /run (async) too. Same gateway, same limit. Switching to async polling doesn’t help.

How do I get the R2 access key for BUCKET_ACCESS_KEY_ID and BUCKET_SECRET_ACCESS_KEY?

Section titled “How do I get the R2 access key for BUCKET_ACCESS_KEY_ID and BUCKET_SECRET_ACCESS_KEY?”

In the Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API Token. Set permissions to “Object Read & Write” scoped to the specific bucket. Cloudflare shows the access key ID and secret access key once; copy both into your RunPod endpoint env vars immediately. The secret isn’t retrievable later.

Yes. The default TTL is 3600 seconds (one hour). If your downstream client picks up the response asynchronously (job queue, cron, etc.), download promptly or bump _S3_PRESIGN_TTL_SECONDS in the worker handler before redeploying.

Can I reuse the same R2 bucket for input and output?

Section titled “Can I reuse the same R2 bucket for input and output?”

Yes. The worker doesn’t care about the bucket layout. Upload your input PDFs to bucket/inputs/ and the worker writes outputs to bucket/<basename>-<hash>.tar.gz at the root. Add BUCKET_PREFIX env var if you want outputs pushed into a subdirectory.

What if I can’t set up R2? Is there a fallback?

Section titled “What if I can’t set up R2? Is there a fallback?”

Page chunking. Split the parse with start_page and end_page into segments small enough that each output tarball stays under 20 MB, then concatenate the .md files client-side. Slower (you may pay multiple cold starts if the worker scales to zero between calls) and you handle joining yourself, but no infra changes needed.

Is the 20 MB cap on /run too, or only /runsync?

Section titled “Is the 20 MB cap on /run too, or only /runsync?”

Both. RunPod’s /run (async) and /runsync (synchronous) share the same gateway and the same payload limits. Switching to async doesn’t help the response-size problem. The cap is at the gateway layer, not the polling protocol.

Does using return: "s3" add to cold-start time?

Section titled “Does using return: "s3" add to cold-start time?”

No. The S3 upload happens at the end of the parse, not the beginning. The handler’s package phase grew from ~95 ms (in-memory tarball) to ~11 s (gzip + upload to R2) on an 82-page job, but cold-start is unchanged. The S3 mode adds a small constant to warm-job latency, not a multiplier.

Effectively unlimited for mineru-runpod workloads. R2 supports multipart uploads up to 5 TB per object. You’ll hit the worker’s executionTimeoutMs long before you hit R2’s per-object limit.

Does R2 work for input PDFs too, or only output?

Section titled “Does R2 work for input PDFs too, or only output?”

Both. The worker accepts file_url pointing at an R2 public dev URL (or a presigned R2 GET URL for private buckets) and fetches the input from R2. This avoids the inbound 20 MB cap on file_b64 for large PDFs. You can run an R2-in / R2-out setup with one bucket and avoid every payload-size limit RunPod has.

If you’ve shipped a multi-page PDF pipeline on RunPod and you’re not using return: "s3", you’ll hit the gateway cap eventually. Set it up before you need it. The cost is ten minutes of env-var configuration and possibly zero dollars per month at indie volumes.

If you’re new to the template, the getting-started guide walks through the full deploy in about ten minutes. For the cold-start side of the picture (separate from the response cap covered here), see the FlashBoot mechanism investigation. For GPU sizing, Choosing a GPU covers when the default ADA_24 (RTX 4090) is enough and when to opt up.

If this saved you time, the easiest way to say thanks is signing up for RunPod through this link. Star the repo on GitHub for updates.


Disclosure: RunPod links in this post use a referral code that credits me at no cost to you. The post would read the same without it.

Serverless MinerU on RunPod: honest cost math (2026)

Last Updated: 2026-05-26

If you’re building a RAG pipeline, a document indexer, or any product that ingests PDFs at scale, you’ve probably hit the same wall I did. Hosted OCR APIs charge pennies per page that compound into thousands per million. CPU parsers are too slow for production volume. A permanent GPU pod is wasteful when traffic comes in bursts.

MinerU 2.5 is genuinely state-of-the-art for PDF → Markdown / structured JSON. Apache 2.0 license. The MinerU2.5-Pro-2604-1.2B model fits comfortably on a 24 GB GPU. RunPod Serverless scales to zero when nothing is calling. Wiring those two together is the obvious move.

Real numbers from my open-source mineru-runpod template, measured on a 24 GB RTX 4090 in May 2026: ~$0.001 per page for warm parses, plus a ~$0.03 fixed tax per cold start. The all-in per-page cost depends on how much work you do before the worker scales back to zero. Here’s the deploy, the response shape, and the workload patterns this template is the right fit for.

What does it actually cost to run MinerU on RunPod Serverless?

Section titled “What does it actually cost to run MinerU on RunPod Serverless?”

About $0.001 per page on an RTX 4090 once the worker is warm. Each scale-from-zero adds a ~$0.03 fixed tax: roughly 110 seconds of GPU billing for vLLM engine init plus model load. Per-page math depends entirely on amortization. Sparse traffic with one short request per cold start lands closer to $0.005–$0.01 per page.

Real workload-shape math using ADA_24 (RTX 4090, ~$1.10/hr Flex):

Workload shape Per-page cost
1,000 pages amortized across one cold start ~$0.001
100 pages amortized across one cold start ~$0.0013
10 pages then idle out ~$0.004
One short doc per scale-from-zero (worst case) ~$0.007

Compared to alternatives:

Tool / setup Per-page cost Notes
Hosted OCR APIs (typical) $0.001 – $0.01 vendor lock-in, rate limits, documents leave your stack
Permanent GPU pod (24 h on A5000) $0.001 – $0.003 24 h of bills whether you use it or not
mineru-runpod, amortized ~$0.001 – $0.004 scales to zero; cold-start tax is real
Marker / Nougat on CPU $0 cash, $$$ time ~30 s/page sequential (Marker docs)

The trick is RunPod’s per-second billing. No worker running, no bill. The catch is every scale-from-zero pays a real fixed cost.

How do I deploy MinerU to RunPod Serverless in ten minutes?

Section titled “How do I deploy MinerU to RunPod Serverless in ten minutes?”

Fork the repo, point RunPod’s GitHub auto-build at your fork, create a Serverless Endpoint with ADA_24 (RTX 4090) and FlashBoot enabled, send a request via the included Python client. Total wall-clock from RunPod sign-up to first parse: roughly ten minutes, dominated by the image build (~5–10 min) plus the first cold start (~110 s).

Sign up here. Add $5 of credit. That covers several thousand cold starts plus a few million warm pages.

Terminal window
gh repo fork sergeyshmakov/mineru-runpod --clone
cd mineru-runpod

The repo stays small. Dockerfile, handler.py, a worker/ package, a Python client (mineru_client), three GitHub Actions workflows, Hub metadata under .runpod/. MIT licensed, ~30 files.

In the RunPod dashboard:

  1. Serverless → Templates → New → Import Git Repository
  2. Point at your fork. Branch main, Dockerfile path Dockerfile.
  3. RunPod clones, builds the image, stores it in its own registry, and gives you a template_id. The build runs ~5–10 minutes. Watch the log if you want.

Dashboard path:

  • Serverless → Endpoints → New
  • Template: the one you just created
  • GPU pool: ADA_24 (RTX 4090, 24 GB)
  • Workers min: 0, max: 3
  • Idle timeout: 10 seconds
  • FlashBoot: on
  • Save, grab the endpoint id

Or as code (reproducible across redeploys):

Terminal window
pip install -e .[deploy]
python deploy.py --template-id <tid>

deploy.py exposes every endpoint setting as a CLI flag.

from mineru_client import MineruClient
client = MineruClient(
endpoint_id="<your-endpoint-id>",
api_key="<your-runpod-api-key>",
)
result = client.parse_document(
file_url="https://example.com/report.pdf",
end_page=4, # smoke test on first 5 pages
)
client.save_tarball(result, "./out/doc")
# → ./out/doc/<basename>.md
# → ./out/doc/<basename>_content_list_v2.json
# → ./out/doc/<basename>_middle.json
# → ./out/doc/images/*.png

First parse pays a cold start. Subsequent parses on the same warm worker run at ~1–6 s/page on the 4090, content density dependent. After 10 s of idle, the worker scales to zero.

What does the MinerU response actually contain?

Section titled “What does the MinerU response actually contain?”

Three structured outputs plus extracted images. <basename>.md is Markdown with LaTeX equations, HTML tables, and image references. <basename>_content_list_v2.json is a flat list of typed entries (text, equation, table, image, code) each tagged with page_idx. <basename>_middle.json carries the full layout with bounding boxes and reading order. Pick the transport via return: tarball_b64, inline, or s3.

For a document indexer or RAG pipeline, content_list_v2.json is the file you’ll spend the most time with. Group entries by level: "title" boundaries for section-based chunking. Embed each chunk and store page_idx for citation back to the source.

The Markdown is for human-readable display. middle.json has bounding boxes per span when you need page coordinates for hover-to-source UI.

Transport options on the request: tarball_b64 (default) for outputs under ~20 MB, inline if you want the markdown directly in the JSON response, s3 for anything that would exceed RunPod’s response cap. See the R2 bridge post for the s3 setup.

When does mineru-runpod fit your workload, and when doesn’t it?

Section titled “When does mineru-runpod fit your workload, and when doesn’t it?”

Good fit: batch ingest jobs, bursty traffic (50 docs in a minute, then quiet), background pipelines, OCR-API replacement. Poor fit: interactive single-document apps (cold starts make users think it’s broken), sparse traffic (one job per cold start dominates the bill), strict latency SLOs without provisioning workers_min ≥ 1.

I run this template in production for a document indexer. Six months of operation, here’s the honest fit picture:

Good fit:

  • Batch ingest. Drop 500 PDFs into a queue. One cold start amortizes across the whole batch at ~$0.001 per page.
  • Bursty traffic. A user uploads 50 documents in a minute. One cold start, 49 warm parses.
  • Background pipelines. Nightly cron processes yesterday’s intake. Cold start cost is rounding error against a multi-hour batch.
  • OCR-API replacement. Comparable per-page cost without shipping documents to a third party.

Poor fit:

  • Interactive single-document parsing. Your user uploads one PDF and waits two minutes for the cold start. They’ll think it’s broken.
  • Sparse traffic (one job every 20–60 min). Almost every request is a cold start. The ~$0.03 cold-start tax dominates. Rent a permanent low-tier GPU pod and skip serverless instead.
  • Strict latency SLOs. Cold-start latency is partly outside your control. Provisioning workers_min ≥ 1 eliminates cold starts but you pay for the warm worker around the clock.

The repo’s defaults (workers_min=0, idle_timeout=10s) are tuned for batch-with-bursts. The dashboard’s scaling settings are where you tune for other patterns.

What’s the real cold-start cost on RunPod Serverless?

Section titled “What’s the real cold-start cost on RunPod Serverless?”

Roughly 110 seconds before MinerU starts parsing your first request after a scale-from-zero. The composition: ~3 s fitness checks, ~20 s vLLM engine config, ~20 s model load, ~25 s torch.compile, ~5 s CUDA graph capture, ~5 s of actual parse. Billed at ~$1.10/hr on the 4090 default, that’s roughly $0.03 per cold start.

The per-phase breakdown is documented in the troubleshooting guide if you want to see where the time goes. The boot-time warmup in this template loads MinerU’s model and JIT-compiles vLLM kernels before the worker accepts requests. When RunPod’s FlashBoot snapshot is available on a subsequent scale-from-zero, the wall-clock drops to ~7–8 seconds because the snapshot captured a warm process. When the snapshot isn’t available (new host, image rebuild), warmup re-runs and you pay the full ~110 s again.

The FlashBoot mechanism investigation covers when the fast path applies, with measured numbers across multiple consecutive cold starts.

What should I watch out for before going to production?

Section titled “What should I watch out for before going to production?”

Three production gotchas the marketing won’t mention. The 20 MB response cap silently drops large outputs (symptom: NoneType after a successful parse — covered by the R2 bridge). execution_timeout defaults to 900 s and won’t cover full books. file_b64 inline payloads cap around 10 MB on the way in. None of these crash the worker; they manifest as confusing client-side errors.

  • 20 MB response cap. RunPod’s /runsync gateway drops responses over ~20 MB. Multi-page parses with embedded images hit this around 50–80 pages. Worker logs done; client gets NoneType. Fix: return: "s3" + Cloudflare R2, walked through in the R2 bridge post.
  • Long-job timeout. Repo defaults execution_timeout=900s (good for ~150–300 pages on 4090). A 5,000-page book is 80–500 minutes depending on content density. Bump execution_timeout for long jobs; the endpoint upper limit is 24 hours.
  • Inline payload cap on the way in. file_b64 requests cap around 10 MB. For bigger files, pass file_url and let the worker fetch from your storage. R2 public dev URLs work well.
  • Cold-start economics. “Pennies per page” depends on amortization. Track average pages per cold start in your logs. If it’s under 30, bump idle_timeout or run workers_min=1.

The repo ships with:

  • Typed Python client (MineruClient)
  • deploy.py / destroy.py for endpoint lifecycle automation
  • Reference adapter pattern for wrapping MinerU output into domain models
  • 96 unit tests, CI on every PR
  • Commitlint + semantic-release for automated CHANGELOG / GitHub Releases

For the deeper context that didn’t fit:

If this saved you time, the easiest way to say thanks is signing up for RunPod through this link. Star the repo on GitHub for updates.

How does mineru-runpod compare to hosted PDF APIs?

Section titled “How does mineru-runpod compare to hosted PDF APIs?”

Per-page cost is in the same ballpark ($0.001–$0.004) when amortizing cold starts across reasonable batches. The differences are control and lock-in. You deploy your own RunPod endpoint, pick your GPU and concurrency, run whichever MinerU version you want, and never send documents to a third party. The trade-off is operating a serverless template instead of consuming a managed API.

Yes. The vlm-auto-engine default backend handles English and Chinese well per the model card. For other scripts (Cyrillic, Arabic, Devanagari, Japanese, Korean), the pipeline backend uses PaddleOCR with script-family models, covering 109 languages. Empirically the Pro VLM also handles Cyrillic correctly even though lang is ignored on the VLM path. Switch backends per-request via the backend field.

What’s the difference between vlm-auto-engine, pipeline, and hybrid-auto-engine?

Section titled “What’s the difference between vlm-auto-engine, pipeline, and hybrid-auto-engine?”

vlm-auto-engine uses MinerU’s 1.2B VLM via vLLM. Fastest on English / Chinese, ~1–6 s/page warm. pipeline uses PaddleOCR plus dedicated layout / formula / table models. Slower (~3–5 s/page) but more memory-predictable (4 GB minimum VRAM) and covers 109 languages. hybrid-auto-engine routes each page through either backend based on content. Highest quality on mixed-content docs; needs 48 GB on dense layouts.

Does the per-page cost include the cold-start tax?

Section titled “Does the per-page cost include the cold-start tax?”

No. The ~$0.001 per page is warm-worker math. Each scale-from-zero adds a roughly $0.03 fixed cost on the 4090 default. Your effective per-page cost is (0.001 × pages) + (0.03 × cold_starts) / pages. For 100 pages across one cold start, that’s $0.0013 per page. For 10 pages, it’s $0.004.

Can I use mineru-runpod with my own MinerU model?

Section titled “Can I use mineru-runpod with my own MinerU model?”

Yes. Fork the repo and update the Dockerfile’s huggingface_hub.snapshot_download call to point at your model. Rebuild and redeploy. The handler is model-agnostic; MinerU’s aio_do_parse resolves whatever model is in HF_HOME at runtime.

ADA_24 (RTX 4090, 24 GB). Switched from AMPERE_24 (A5000) on 2026-05-26 after measuring per-page cost. The 4090 is 2–4× faster per page than the A5000 and cheaper per page despite the higher hourly rate. See Choosing a GPU for the full math and when to opt up to 48 GB.

How do I keep my RunPod endpoint warm to avoid cold starts?

Section titled “How do I keep my RunPod endpoint warm to avoid cold starts?”

Set workers_min=1 on the endpoint. You pay for the always-on worker around the clock (~$0.000306/s on the 4090 default, so ~$26/day or ~$800/month). Worth it if your traffic is steady enough that the warm worker stays busy, or if your latency SLO can’t tolerate the cold-start window. For bursty traffic, workers_min=0 with FlashBoot enabled is usually cheaper.


Disclosure: RunPod links in this post use a referral code that credits me at no cost to you. The post would read the same without it.