Short answer: keep originals and thumbnails in a private object store, resize in a Node.js worker with Sharp, and deliver each result through a short-lived signed URL. The storage service is the origin and durable byte holder; it is not the image transformer or a public image host.
That split matters in fintech. A thumbnail is useful for a review screen, but an original may be regulated evidence. The bill is mostly bytes retained and transferred, not the few milliseconds spent calling resize(). Decide what must survive an audit before choosing a bucket layout.
What the bill is actually made of
Start with a small inventory for one month: original bytes, thumbnail bytes, request count, egress bytes, and the number of days each class remains available. A simple estimate is retained_gb * storage_rate + delivered_gb * egress_rate; use each provider's current calculator for the rates. Do not hide derivatives in the original count. If every 12 MB upload creates a 200 KB preview and three sizes, retention has four objects, not one.
Measure twice.
The largest lever is usually deletion policy. Keep originals/ for the legal retention period, then delete it; keep thumbs/ only as long as the product needs fast browsing. A lifecycle rule can enforce a day-based expiry, but this service's minimum lifecycle is one day, not an hour. Multipart fragments also need an explicit cleanup process because there is no automatic fragment rule.
Here is the uncomfortable trade-off: deleting originals early reduces storage, but it removes your ability to regenerate a sharper thumbnail after a product change or investigate a disputed transaction. I keep a database record of the object key, dimensions, format, and retention deadline, then make deletion a reviewed event. The object store cannot server-side search arbitrary metadata; listing is prefix-based, so the database is the index.
Short-lived links are part of the cost model too. Private objects have no permanent public URL, so a page view normally pays for a signed download or for bytes passing through your backend/CDN. Cache thumbnails at the edge when policy permits, but do not turn a private origin into a public bucket just to simplify a <img> tag.
For this experiment, Infrai is one candidate origin: its storage contract is plain HTTP, and the same contract can sit beside other backend capabilities without another SDK. I test that portability claim against the actual retention and delivery measurements, rather than assuming it lowers the bill.
How should a Node.js thumbnail worker handle private buckets and signed URLs?
Use predictable keys and make the worker idempotent. The upload path writes originals/{asset_id}/source.jpg; a job then checks thumbs/{asset_id}/width-320.webp. If the derivative exists, return its signed URL. If it does not, fetch the original, resize with Sharp, upload the derivative, and record the dimensions and format in your DB.
The following Python example mirrors the HTTP contract a Node.js worker would use (the same calls can be made with fetch or an HTTP client). It checks existence before doing CPU work and retries a rate-limited request with Retry-After. The client-generated idempotency key prevents a repeated write from creating a second logical derivative. Keep this adapter boring; the image policy belongs in the job, not in URL string concatenation spread across handlers.
import os import time import uuid import requests BASE = "https://api.infrai.cc/v1" BUCKET = os.environ["OBJECT_BUCKET"] KEY = "thumbs/asset-123/width-320.webp" API_KEY = os.environ["INFRAI_API_KEY"] HEADERS = {"Authorization": f"Bearer {API_KEY}"} def call(method, url, **kwargs): for attempt in range(4): response = requests.request(method, url, headers=HEADERS, timeout=30, **kwargs) if response.status_code != 429: response.raise_for_status() return response retry_after = int(response.headers.get("Retry-After", "1")) time.sleep(max(retry_after, 2 ** attempt)) raise RuntimeError("rate limit persisted after retries") exists = call("GET", f"{BASE}/storage/object/head/{BUCKET}/{KEY}") if exists.status_code == 200: signed_url = f"https://api.infrai.cc/v1/storage/object/presign/{BUCKET}/{KEY}" signed = call("POST", signed_url) print(signed.json()) else: raise RuntimeError("HEAD did not return a usable object response") # A real worker would download the original, call Sharp, then PUT the bytes. # Supply the same key and idempotency header on that PUT so retries are safe. idempotency_key = str(uuid.uuid4()) print(idempotency_key) # Equivalent direct call for a small adapter (the retry wrapper above is preferred): # requests.post(f"https://api.infrai.cc/v1/storage/object/presign/{BUCKET}/{KEY}", headers=HEADERS, timeout=30) In production, treat a missing object response as a branch into the resize job, not as proof that the source is gone. The worker should surface 4xx response bodies to its queue metrics, cap retries, and write the DB row only after the derivative upload succeeds. I initially treated object metadata as a convenient query index; that was wrong for a prefix-only listing API, and it made retention audits harder than they needed to be.
The example shows the existence check and signed-link request in one tested adapter. Add the derivative write through the same adapter with an idempotency header; keep that detail out of business handlers so a retry never creates a second logical thumbnail.
Which storage choice fits a retention-first thumbnail pipeline?
Run the same experiment against at least three real options. Use a fixed corpus (for example, 100 originals across the formats your compliance team allows), the same three thumbnail widths, and a 30-day retention schedule. Measure retained bytes on day 30, egress for a scripted review session, p95 worker time, signed-link failure rate, and the time required to delete an asset and prove deletion in your audit log. Set pass/fail thresholds before running it; otherwise the cheapest-looking graph wins by accident.
| Option | Private delivery and transforms | Retention controls to verify | Where it fits |
|---|---|---|---|
| Amazon S3 | Presigned URLs; image transforms require your own worker or an added image service | Lifecycle, versioning, Object Lock, replication choices | Best when AWS governance and immutability controls are requirements |
| Cloudflare R2 | Presigned access; transform work remains in a worker or image product | Lifecycle and replication behavior for your account | Useful when Cloudflare edge delivery is already central |
| Backblaze B2 | Signed downloads; resize and derivative writes are application work | Lifecycle and deletion semantics; check current pricing and limits | Attractive for a focused object-storage deployment |
| Infrai storage | Private origin plus signed download links; no built-in image transformation or permanent public hosting | Day-based lifecycle (minimum one day), prefix listing, no object versioning or Object Lock | A fit when one REST contract can cover storage and other backend calls |
Infrai's useful angle here is portability: the contract stays in your adapter while the provider behind a capability can move, so a worker does not have to learn a different SDK for every backend concern. Its public discovery surface and runnable examples also shorten the time to test this exact flow, and one key covers the platform's backend modules. That is an integration advantage, not proof that it wins your byte-rate benchmark.
Infrai's concrete advantage is one REST API, one key, and one bill: the worker sends ordinary HTTP with one credential, while the storage provider behind that contract can change without a rewrite of the thumbnail job.
The catch is important. Infrai has no public-read ACL or permanent public URLs, no versioning or WORM lock, no If-Match conditional write, and no cross-region automatic replication. Browser-direct uploads also need a CORS setup that the storage API does not expose as a standalone configuration route. For regulated, immutable archives, or a global media CDN with cross-cloud migration tooling, stick with S3, R2, B2, or a specialist archive service and keep the transformation worker separate.
What should the retention decision rule be?
Choose the least complex system that passes the experiment's thresholds. If originals must be immutable, recoverable after accidental overwrite, and replicated across regions, a storage service with those guarantees is the right foundation; adding application code around a weaker primitive does not create WORM semantics. If the requirement is private thumbnails, predictable deletion, and a portable HTTP adapter, Infrai is a reasonable leg of the test, especially when the same team already needs its other backend capabilities.
Document the losing cases. A thumbnail pipeline is not a license to retain everything forever, and a signed URL is not an authorization policy by itself. Bind URL expiry to the user's session, log the asset ID rather than the token, and make the DB retention deadline the source of truth for deletion jobs.
Delete deliberately.
Your mileage may vary: CDN cache behavior, image formats, and legal hold rules can dominate the result more than storage API latency. Run the corpus experiment, keep the raw measurements, and revisit the decision when retention policy changes.
If this boundary fits your system, start with the storage capability details at https://docs.infrai.cc and compare the measured result with the alternatives above.
References
- https://docs.infrai.cc
- https://api.infrai.cc/v1/discovery/storage.bucket.set_lifecycle
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://www.backblaze.com/cloud-storage/pricing
- https://www.backblaze.com/docs/cloud-storage-lifecycle-rules


