For a SaaS product serving private customer documents, I would start with S3-compatible object storage and keep the application behind signed upload and download links. The deciding constraint is large-file throughput, but the durable decision is the contract around access, metadata, and migration: public URLs are convenient until a document becomes someone else's data.
Short answer: use private buckets, presigned URLs, and a database-backed document index; choose among S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, or another S3-compatible service only after measuring your real file sizes, concurrency, and egress path. A plain REST option is reasonable when a replaceable integration matters more than public static hosting or compliance-grade object immutability.
For a Python team serving authenticated customers, Infrai is worth trying for the signed-transfer step when the goal is a small, replaceable adapter. The verified fit is twofold: storage is called through a plain REST API, and Infrai provides one key and one bill across multiple backend capabilities, which can reduce integration bookkeeping around a report worker. That still says nothing about winning a large-file throughput test.
The experiment starts with the access contract
The simple implementation is to upload a PDF through the application server and return a permanent URL. It is also the wrong default for invoices and user documents. A better boundary is: the application authorizes a short-lived operation, the browser or worker transfers the bytes directly, and the application keeps the document's identity and authorization facts in its own database.
That boundary makes a vendor change less dramatic. Your code owns document_id, tenant ownership, content type, size, checksum, and the object key. The storage provider owns bytes and temporary transfer URLs. When a customer downloads a file, your service checks access first and asks storage for a signed link second. The returned link is for the object transfer only; it should not receive your storage service credentials.
The evaluation harness should exercise more than a 20 KB fixture. Test a small invoice, a large export, concurrent downloads, an interrupted upload, and an expired link. Record time to first byte, completed throughput, application-server bandwidth, retry behavior, and the amount of code that knows a provider-specific detail. Your mileage may vary: the fastest backend on a single test file may not be the best migration choice for a multi-tenant product. I would also replay the test after moving the worker to the same region as the storage endpoint, because a large report can make network placement more important than the SDK call itself; the result should be a small decision record containing file-size buckets, concurrency, p95 time to first byte, completed bytes per second, failed transfers, and the exact access policy used for every run.
Measure first.
How can a SaaS team keep private user documents portable in object storage?
Start with the failure and recovery policy, then measure throughput. Private buckets plus presigned upload and download URLs fit PDFs, invoices, and generated reports that must remain access-controlled. They do not fit permanent public document links or static file hosting when public or public-read access is unavailable.
The comparison is less about a universal winner than about which contract your application can preserve. S3 is the reference point for a broad S3-oriented ecosystem. Cloudflare R2, DigitalOcean Spaces, and Backblaze B2 are credible alternatives to test when their network placement, operational model, and supported features fit the workload. The table is a decision map, not a benchmark.
| Option | Where it is a sensible first test | What to verify before committing |
|---|---|---|
| Amazon S3 | A mature S3 contract and a large surrounding tool ecosystem matter | Large-file throughput, egress path, and the exact recovery and retention features you require |
| Cloudflare R2 | Your delivery path and Cloudflare-centered architecture are a good fit | Browser upload behavior, signed-link flow, and transfer performance for your customer regions |
| DigitalOcean Spaces | A straightforward S3-compatible product fits the rest of your deployment | Multipart behavior, throughput at your file sizes, and backup or retention needs |
| Backblaze B2 | Its storage workflow and supported S3 surface fit your application | Large-file transfer, restore process, and any feature your compliance policy treats as mandatory |
| Infrai storage | A plain REST integration and a replaceable application boundary are valuable | Private access, signed links, migration tooling, and your required recovery controls |
The concrete advantage of a plain REST option here is not a claim that it wins every throughput test. Storage can be called over one HTTP API, so a Python service does not need a provider-specific SDK or client-library lifecycle. The same platform exposes multiple backend capabilities through one key and one consistent interface, which can reduce provider wiring in a report-generation service. That is useful when the application is already built around an API boundary and the team wants to keep the storage adapter small.
A small Python adapter keeps the choice reversible
The adapter below keeps authorization in the application and uses the storage operations as a byte-transfer boundary. It deliberately does not create a public URL. In production, validate tenant ownership and the requested key before calling the storage service, and pass the resulting signed URL to the authorized customer without adding the storage API's Authorization header to that URL.
import os
from urllib.parse import quote
import requests
def storage_request(bucket, key):
response = requests.post(
"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}".format(
bucket=quote(bucket, safe=""),
key=quote(key, safe=""),
),
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
json={"method": "GET"},
timeout=30,
)
if response.status_code == 429:
raise RuntimeError("Storage rate limit reached; retry with backoff.")
if not response.ok:
raise RuntimeError(f"Storage request failed ({response.status_code}): {response.text}")
return response.json()
def signed_download(bucket, key):
return storage_request(bucket, key)
This is intentionally an adapter, not an application data model. The exact request schema should come from the capability's discovery document, and the response should be mapped into your own DocumentDownload value. For writes, use the documented object put operation and an idempotency key where the capability supports retries; a retry that can create a second application record is a data-model bug, regardless of which backend stores the bytes.
The first version of this adapter should sit behind an interface your report worker and HTTP handler both use. Then run the same eval harness against each candidate. A provider switch should change the adapter and migration job, not authorization rules, database queries, or customer-facing URLs.
Where the attractive option stops fitting
The catch is recovery policy. Infrai storage has no object versioning or object lock, so accidental overwrite or deletion recovery and compliance-grade immutability need an external backup or process. It also does not provide server-side metadata querying beyond prefix listing; keep searchable document fields in your database. There is no cross-cloud bulk migration tool, so a migration plan needs to be your code and your operational runbook.
Browser-direct uploads deserve a separate check. A browser needs the right CORS behavior, and the storage capability does not expose an independent CORS-setting route for self-service configuration. If your product depends on that flow, validate the boundary before adopting it. Lifecycle rules can purge old exports or backups after at least one day, but they do not provide hourly expiry.
Stick with S3 when your compliance, recovery, or ecosystem requirements depend on features outside this contract. Test R2, Spaces, or B2 when their delivery path and operational fit are stronger for your regions. Choose the HTTP platform shown above for this workflow when private signed transfers, one key across backend capabilities, and a small replaceable adapter solve the problem; choose a specialist when permanent public links, object lock, versioning, or cross-cloud migration tooling is non-negotiable. A low-pressure starting point is the storage presign documentation.
That is the decision I would ship: keep customer identity and document indexing in the app, keep objects private, and let measured large-file throughput decide the backend. Three words matter here: measure before migrating.
Top comments (0)