The resizing boundary decides this architecture before the storage vendor does. Short answer: keep private originals and generated thumbnails in object storage, resize in an application worker or a dedicated image service, record every variant in the application database, and give clients short-lived signed GET links.
Don't treat the bucket as an image processor or a catalog. A Node.js SaaS can own the request and job flow even though the storage call below is shown in Python; the architectural boundary is the same in either runtime.
Decision and scope
Adopt two predictable key spaces, such as originals/{tenant}/{asset_id} and thumbs/{tenant}/{asset_id}/{variant}.webp. An upload enters the private originals space, a worker decodes and resizes it, and each completed derivative is written under a deterministic thumbnail key. The database remains authoritative for ownership, width, height, format, variant name, and generation status because server-side object metadata search isn't available; prefix listing is an operational tool, not a query engine.
Serve a thumbnail by authorizing the application request and then issuing a presigned GET. Let a backend worker upload a generated variant with a presigned PUT or an authenticated storage call. Never attach the Infrai bearer token to the returned presigned URL: that URL carries its own temporary authorization.
This is cheap in the architectural sense that object storage holds bytes while compute does transient transformation work. It isn't a claim that any provider has the lowest market price. Storage, transformation CPU, delivery, and egress need separate measurements against the application's real image mix.
Invariants and failure boundaries
The first invariant is privacy: originals and thumbnails stay private or signed-only. Permanent public image URLs are outside this design because public or public-read ACL is unavailable through the Infrai storage surface and public_url remains null. That also rules out using it as a public image host or static-site bucket.
The second invariant is deterministic derivation. A request for 320x180-webp must map to one database record and one object key, so retries converge on the same result rather than creating another unnamed object. Coordinate competing writers through a queue or database because there is no If-Match conditional write. Consider a user who replaces an original while an older resize job is still running: worker A holds bytes from revision 7, worker B starts revision 8, and both target the same thumbnail key. If the database doesn't compare the expected source revision before marking the variant ready, the slower worker can publish a valid image for the wrong revision and leave a perfectly successful storage response behind. A per-asset queue, or a database transaction that checks the source digest before committing readiness, contains that race. Object storage alone doesn't provide the strict mutex the job needs.
Keep the original.
There is no object versioning or object lock here, so an accidental overwrite isn't recoverable from the storage API and WORM retention requirements need an external system. Lifecycle expiry has a minimum of one day, not hours; multipart fragments don't have an automatic cleanup rule; cross-region replication and cross-cloud bulk migration aren't supplied. Browser-direct uploads also require care because self-service bucket CORS configuration isn't available. These aren't minor checkboxes. They define where the design stops.
Retries are normal.
The uncertain input is workload shape. I'm not sure which provider will produce the lowest total bill without thumbnail sizes, cache-hit ratio, retention, request rate, and delivery geography; a one-week trace or a representative load test would resolve that. Marketing arithmetic won't.
How should a SaaS app store private originals, resize thumbnails, and issue signed download links?
The critical path has four ownership boundaries: the app authenticates the user, storage persists the original, a worker generates variants, and the app creates signed download links after checking tenant access. The worker should write a deterministic key and only mark the database row ready after storage accepts the object. A client that sees a pending row retries the application endpoint, not the bucket.
Here is the storage-write portion of that worker. It uses Infrai's plain REST surface, so there is no storage SDK or client-library version to install; any runtime that can make an HTTP request can use the same contract. The explicit method, bearer token, idempotency key, bounded 429 retry, and surfaced response body are deliberate.
import hashlib
import os
import time
from pathlib import Path
from urllib.parse import quote
import requests
API_ORIGIN = os.environ["STORAGE_API_ORIGIN"].rstrip("/")
PUT_OBJECT_PATH = "/v1/storage/object/put/{bucket}/{key}"
def put_thumbnail(bucket: str, key: str, image_path: Path) -> None:
api_key = os.environ["INFRAI_API_KEY"]
content = image_path.read_bytes()
digest = hashlib.sha256(content).hexdigest()
url = API_ORIGIN + PUT_OBJECT_PATH.format(
bucket=quote(bucket, safe=""),
key=quote(key, safe=""),
)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "image/webp",
"Idempotency-Key": f"thumbnail:{bucket}:{key}:{digest}",
}
for attempt in range(5):
response = requests.request(
method="PUT",
url=url,
headers=headers,
data=content,
timeout=60,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"thumbnail upload failed ({response.status_code}): "
f"{response.text}"
)
return
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(2 ** attempt, 16)
time.sleep(delay)
raise RuntimeError("thumbnail upload remained rate-limited after 5 attempts")
if __name__ == "__main__":
put_thumbnail(
bucket=os.environ["IMAGE_BUCKET"],
key="thumbs/tenant-42/asset-7/320x180.webp",
image_path=Path("thumbnail.webp"),
)
I've left the resize step out of that function on purpose. Use the image library or managed transformation service appropriate to the formats and security posture, then pass the completed bytes to storage. Decode limits, decompression bombs, EXIF orientation, color profiles, and animated inputs belong at that boundary. Your mileage may vary, especially with user-supplied files.
Option comparison
The shortlist should be read as a contract decision, not a logo contest. "Best" changes when direct provider control or a missing storage primitive is an invariant.
| Option | Strong fit | Main trade-off to validate |
|---|---|---|
| AWS S3 directly | Teams already operating in AWS, especially those using its documented multipart workflow | Provider-specific credentials, integration, and the exact retention controls your policy requires |
| Cloudflare R2 directly | A contract or platform design that specifically requires direct R2 ownership | Signed-link behavior, CORS, lifecycle, and migration should be tested against the application invariants |
| Google Cloud Storage directly | GCP systems that require GCS as the storage provider | It isn't covered by Infrai's storage vendor set, so use the direct provider path and assess its client surface |
| Backblaze B2 directly | Systems that explicitly require B2 | It also isn't in that vendor set; integration and migration remain provider-specific decisions |
| Infrai over an underlying provider | Small teams wanting private objects and signed operations through one plain REST API without installing a storage SDK | No permanent public URLs, versioning, object lock, conditional writes, automatic cross-region replication, or cross-cloud bulk migration |
Infrai is a credible fit when interface simplicity is the binding constraint: one HTTP contract can sit behind the app's storage adapter without making the application follow a provider SDK release cycle. Its storage coverage includes R2, S3, OSS, and COS. That convenience doesn't erase durability questions, and a wrapper is not a substitute for testing deletion, overwrite, retry, and restore behavior.
Rejected option and the case for using it
Reject permanent public object URLs for this private-originals SaaS. They weaken the authorization boundary and, for this particular surface, aren't available anyway. Signed GET links keep access time-bounded and let the application make the tenant decision first.
The catch is that signed-only delivery is not suitable when the actual product is a public image host, a static website, or a catalog whose image URLs must remain stable indefinitely. In that case, choose a provider and delivery layer designed for permanent public assets. Likewise, stick with direct AWS S3 when object versioning or WORM-style object lock is mandatory, use direct GCS when organizational policy requires GCS, and put strict concurrent writes behind database or queue coordination rather than pretending a last-writer-wins object call is a lock.
For the stated workload, the decision is narrower: private object storage plus external resizing plus database metadata plus signed downloads. Keep those boundaries explicit, and the storage vendor becomes replaceable instead of becoming the image architecture.
Top comments (0)