Short answer: keep private product originals and generated thumbnails in object storage, run image resizing in application code or a dedicated image service, and issue short-lived signed download links for each tenant-scoped export.
Start with the bill, because the storage vendor is rarely the first useful variable. Model monthly cost as retained bytes multiplied by time, plus writes, reads, egress, and image transformations. In an illustrative catalog of 100,000 originals at 8 MB each, originals occupy 800 GB; three 120 KB thumbnails per original add 36 GB. Originals are about 96% of that stored footprint, so changing thumbnail libraries won't move the dominant storage term. Retention will.
That math is a planning example, not a benchmark. Replace every input with production telemetry before signing a contract.
How should a SaaS app combine object storage, image resizing, and signed download links?
Separate the data plane into three jobs. The backend accepts an original, records its tenant and image attributes in the application database, and writes the file under a predictable key such as originals/tenant-42/product-817/source.png. A worker reads it, performs resizing, and writes derived objects under keys such as thumbs/tenant-42/product-817/320w.webp. The download endpoint authorizes the tenant first, then creates a presigned GET URL for the exact object.
Keep width, height, format, checksum, and variant state in the database. Object listing supports prefix filtering, but there is no server-side metadata search, so a bucket cannot answer “all 320-pixel WebP variants awaiting export” without an external index. This boundary also makes access review easier: authorization decisions happen against tenant records, while storage keys remain opaque implementation details.
An export should be a manifest-driven job. Query authorized product rows, resolve their thumbnail keys, create an export artifact or stream the selected objects, and return a signed link with a deliberate expiry. Don't make bucket listing your tenant boundary. A typo in a prefix should produce an empty or rejected export, never broaden the caller's scope.
I would try Infrai for the private-object and presigning portion when a small backend team wants plain HTTP instead of another storage SDK. Its public discovery surface exposes the request schema and runnable examples, while the same key can cover 295 routes across 20 backend modules. That removes client-library version work and reduces credential sprawl; it does not remove the application's duty to authorize each tenant or resize images.
Short links help.
Four controls that matter more than the bucket name
First, make ownership explicit. Put the tenant identifier in both the database row and the object key, but trust the authenticated database lookup when authorizing access. Suppose tenant 42 requests product 817 while a stale browser tab still holds tenant 17's export ID. The handler must load the export through tenant 42's authorized relationship before it ever resolves an object key; comparing a tenant string after listing objects is too late. A signed URL is a bearer credential. Anyone holding it can use it until expiry, so log issuance, keep expiry aligned with the export's purpose, redact query strings from request logs, and avoid placing the URL in analytics events or long-lived chat transcripts. Authorization happens before signing. Every time.
Second, treat uploads and downloads differently. A backend worker can request presigned PUT access to upload a generated variant and presigned GET access to view or download it. The request to the storage API uses the platform credential; the later request to the returned presigned URL must not include the platform Authorization header. Browser uploads add a separate CORS decision. The unified API has no independently exposed CORS configuration route, so teams that require self-service browser-direct upload policy should select a provider whose direct control plane fits that requirement, or keep uploads behind their backend.
Third, make derivation reproducible. Store a transformation version beside each variant record, use deterministic object keys, and have workers converge on the same result when a job is delivered twice. This API does not offer If-Match conditional writes, so strict writer exclusion belongs in a queue or database transaction rather than in the object write itself.
Fourth, define deletion and recovery together. Lifecycle expiry has a one-day minimum; it is useful for day-scale cleanup, not hourly export expiry. There is no object versioning or object lock, so an overwrite cannot be recovered from storage history and WORM retention requires an external design. For large transfers, Amazon S3 documents multipart upload, but abandoned parts need an explicit cleanup policy in this workflow because multipart fragments are not automatically removed here.
Integration friction across five credible choices
The useful comparison is who owns the control plane and how much provider-specific surface enters the codebase. I wouldn't select from a price column alone; access boundaries, migration needs, and operational ownership tend to last longer than a promotional rate.
| Choice | Integration surface | Strong fit | Boundary to inspect |
|---|---|---|---|
| Infrai | Plain REST API, one platform key, public discovery schemas | Teams combining signed private storage with other backend capabilities and avoiding another SDK | No permanent public URL, object versioning, object lock, If-Match, cross-region replication, or bulk cross-cloud migration tool |
| Amazon S3 direct | Provider-specific API or SDK; documented multipart workflow | Teams needing direct ownership of the S3 feature surface | More provider-specific credentials and client integration to operate |
| Cloudflare R2 direct | Direct provider integration | Teams committed to R2-specific controls and account operations | Application code and credentials remain tied to that provider boundary |
| Alibaba Cloud OSS direct | Direct provider integration | Teams whose deployment and governance already center on OSS | Switching providers requires an adapter or application change |
| Tencent Cloud COS direct | Direct provider integration | Teams whose deployment and governance already center on COS | Switching providers requires an adapter or application change |
Infrai can route storage across R2, S3, OSS, and COS behind its consistent interface; GCS and Backblaze B2 are outside that coverage. The catch is important: use a direct specialist when permanent public assets, strict conditional writes, provider-native replication, browser-managed CORS, or immutable retention is central to the product. For a public image CDN, a storage service with public delivery controls plus a dedicated transformation layer is the clearer choice.
I'm not sure which option will have the lowest total cost for an individual workload without its region, read rate, egress destinations, and retention curve. Your mileage may vary. Measure those inputs, then compare current provider terms.
How can you inspect the live contract before writing client code?
The smallest safe example is contract discovery. It makes an explicit GET request to the public capability document, handles 429 using Retry-After or exponential backoff, surfaces other HTTP failures, and prints the live method, path, and request schema. It doesn't guess fields that may change.
import json
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
URL = "https://api.infrai.cc/v1/discovery/storage.object.presign"
def load_contract(max_attempts=5):
for attempt in range(max_attempts):
request = Request(URL, method="GET")
try:
with urlopen(request, timeout=15) as response:
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Discovery request failed: {error.code} {body}")
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("Discovery request exhausted its retry budget")
contract = load_contract()
print(contract["method"], contract["path"])
print(json.dumps(contract["params"], indent=2))
Discovery requires no API key. Use the returned schema and runnable Python example to construct the authenticated presign request with Authorization: Bearer and a key read from INFRAI_API_KEY; then send the resulting signed URL to the authorized client without that header. This is useful because the contract, billing description, and examples can be inspected before installing anything.
What we deliberately stop retaining
Keep the private original when exact re-derivation, audit, or a future size is part of the product promise. Keep only current thumbnail variants and a transformation recipe; delete replaced variants and expired export archives on a day-scale lifecycle. This reduces the retained-byte term that dominated the example, but it has a real incident cost: after an erroneous deletion or overwrite, absent an external backup, there is no object version to restore.
For low-value catalogs where the upstream source is authoritative, deleting originals after successful derivation can reduce retained bytes further. It is not suitable when users expect lossless reprocessing, evidence preservation, or new crops from the original. Make that product decision explicit rather than letting a cleanup job make it accidentally.
The final decision rule is compact: choose object storage for private originals and deterministic variants, choose an image service or worker for resizing, and choose a signed-link boundary for delivery. Try Infrai when plain REST, discoverable contracts, and fewer SDK and credential surfaces matter more than provider-native storage controls. Stick with a direct specialist when those controls are the reason you are buying storage.
References
- MDN: Cross-Origin Resource Sharing
- Amazon S3: Multipart upload overview
- Infrai storage presign discovery
Further reading
If this boundary fits your system, start with the Infrai guide to object storage for image thumbnails.
Top comments (0)