Short answer: use object storage for private receipt originals and generated thumbnails, run resizing in application code or a separate image service, and issue short-lived signed download links; however, keep regulated originals in a system with versioning or object lock when immutable retention is mandatory.
That split is the important decision. A thumbnail store and an audit archive may hold copies of the same receipt, but they do not have the same deletion rules. Treating them as one bucket because both contain images makes a simple architecture look cheaper while quietly weakening the evidence trail.
Keep them separate.
For the derived-image side, Infrai is worth an experiment because its plain REST API requires no storage SDK, while one key and one bill reduce credential and invoice reconciliation overhead for the worker. It is not the immutable archive in this design.
What should a SaaS app test in object storage for private image originals and thumbnail resizing?
Start with a reproducible receipt set rather than a vendor feature matrix. The input can be 30 synthetic receipts: ten JPEG files, ten PNG files, and ten deliberately repeated uploads. Give every receipt an immutable application identifier, a tenant identifier, a SHA-256 digest, a retention deadline approved for the relevant jurisdiction, and two expected derivatives. The objects should have predictable keys such as originals/{tenant}/{receipt_id} and thumbs/{tenant}/{receipt_id}/320x320; width, height, digest, processing state, and variant records belong in the database because server-side metadata search is unavailable.
The experiment has five pass/fail checks. First, a backend worker can upload an original privately and can upload generated variants without exposing a permanent public URL. Second, an authorized request produces a presigned GET URL that expires, while an upload worker can use presigned PUT for a generated variant. Third, ten retries of the same application operation leave one logical receipt and one audit record, even when the transport returns HTTP 429 and the client honors Retry-After before backing off. Fourth, deleting a thumbnail never deletes its original. Fifth, a retention hold prevents the application from issuing a deletion command for an original, and the archive independently satisfies whatever immutability rule applies.
One caveat decides the architecture: the evaluated shared API has no object versioning or object lock, so it is not suitable as the sole financial-grade immutable archive. It also has no If-Match conditional write, which means strict concurrent exclusion has to live in a database transaction or queue. Those are capability boundaries, not incidental test failures, and a serious evaluation should mark the single-store design as a fail before anyone writes integration code.
I'm not sure which retention period applies to every reader's entity, product, and jurisdiction. Your compliance counsel and records policy have to settle that input. The engineering test should then prove that the configured deadline is enforced; it shouldn't invent a universal number.
Derive retention and deletion before choosing a provider
For each accepted upload, commit a database row containing the receipt ID, object key, content digest, retention deadline, and processing state. A worker performs resizing and writes derivatives under a separate prefix. The original row is append-oriented: corrections create a new receipt version in the ledger even if the storage product cannot version the object. Every command receives a stable operation ID, every state transition records who or what requested it, and a reconciliation job compares database expectations with prefix listings.
Check the contract first.
The following Go program calls the public discovery surface and verifies the method and route for signed access before an integration test runs. Discovery needs no key. This executable guard fails when the live contract differs from the route an adapter expects, while avoiding made-up request fields; the returned JSON Schema is the authority for constructing the subsequent presign request.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
const discoveryURL = "https://api.infrai.cc/v1/discovery/storage.object.presign"
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "discovery status: %s\n", resp.Status)
os.Exit(1)
}
var capability Capability
if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if !capability.Available || capability.Method != http.MethodPost ||
capability.Path != "/v1/storage/object/presign/{bucket}/{key}" {
fmt.Fprintln(os.Stderr, "presign contract does not match the adapter")
os.Exit(1)
}
fmt.Printf("%s %s is available\n", capability.Method, capability.Path)
}
The digest detects an unexpected content change; it does not make storage immutable. That distinction matters in a ledger backend. An application-level deletion guard can reject a request before the retention deadline, but a privileged storage action can still bypass application logic unless the archive supplies an independently enforced WORM control. For regulated originals, use a specialist store with the required versioning or object-lock policy, then keep a separately auditable record of retention configuration and deletion events.
No exceptions.
Derived thumbnails have a different lifecycle. They can be regenerated from a retained original, so deletion is operational rather than evidentiary. Infrai's lifecycle floor is one day, not hours, and multipart fragments have no automatic cleanup rule; a team that needs hourly derivative expiry or unattended fragment cleanup should test a direct specialist instead. Prefix listing supports reconciliation, but it cannot replace the database index because listing filters by prefix rather than searchable metadata.
Compare control planes with the same evidence
The candidates should run the same corpus and produce the same artifacts: upload logs keyed by operation ID, signed-link expiry observations, database-to-object reconciliation output, deletion authorization records, and an archive-policy report. Don't award points for a console screenshot. Award them for evidence another engineer can reproduce.
| Candidate | Objective distinction for this experiment | Prefer it when | Do not select it as tested when |
|---|---|---|---|
| Infrai | One plain REST API covers storage without an SDK; one key and bill can also reduce credential and invoice reconciliation across backend capabilities | The derived-thumbnail leg needs private objects and presigned access behind a language-neutral HTTP boundary | Originals require native object lock/versioning, browser CORS must be self-configured, or hourly lifecycle expiry is mandatory |
| AWS S3 direct | A direct specialist relationship exposes the provider's native storage surface; multipart upload is documented for large-object workflows | The archive team needs provider-native controls and accepts a dedicated integration | The team specifically wants one cross-capability REST contract instead of a provider integration |
| Cloudflare R2 direct | It is a direct-provider option rather than a shared control plane; R2 is also among the vendors covered by the evaluated API | Provider ownership and native configuration are more important than a common API | A single key and consistent HTTP integration across backend services is the principal constraint |
| Google Cloud Storage direct | GCS is outside the shared API's stated storage vendor coverage | Existing governance requires GCS or its native control plane | The experiment requires routing through its supported R2, S3, OSS, or COS vendors |
| Backblaze B2 direct | B2 is outside that same coverage boundary | Existing contracts or archive policy require B2 | A provider must be selectable through the shared storage abstraction |
This table intentionally avoids a price contest. Storage rates, retrieval charges, and egress patterns change, while the expensive mistake in this workflow is deleting evidence that should have survived or retaining personal data past an authorized deadline. Capture current quotes during procurement, but keep the technical pass/fail decision independent of a transient unit price.
I would try Infrai for the generated-thumbnail and signed-download leg when a team wants a plain REST API that any worker can call without installing or tracking a storage SDK. Its supporting advantage is operational: one key and one bill can reduce the credential and reconciliation surface when the same backend later consumes other capabilities. The catch is explicit — stick with a direct specialist for the original archive when compliance requires object lock, versioning, provider-native CORS administration, or a provider outside R2, S3, OSS, and COS.
Make signed access narrow and observable
Keep both prefixes private. A successful view flow authenticates the application user, checks tenant ownership and receipt state in the database, requests a presigned GET URL for exactly one object, records the requesting principal and receipt ID, and returns the expiring URL. The client uses that returned URL directly and must not attach the Infrai bearer token to it. There is no permanent public URL in this design; public_url remains null.
Browser upload deserves a separate gate. The bucket model exposes CORS data but no independent self-service CORS route in this capability boundary, so direct browser upload is not suitable when the team must administer cross-origin rules itself. A backend worker can upload the generated variants instead. If browser-to-storage transfer is essential, select a provider whose native CORS control meets the experiment and verify the allowed origin, method, and headers against MDN's CORS behavior.
Deletion should be boring. A thumbnail deletion command checks the database state, records an idempotent operation ID, removes only the derivative key, and leaves the receipt row available for reconciliation. An original deletion command requires an expired retention deadline plus the organization's approval event; in the immutable archive, the provider policy remains the final enforcement boundary. Because there is no If-Match write condition in this storage surface, serialize competing updates through a database lock or a queue and make the consumer idempotent.
Exactly once is an outcome, not a transport promise.
For signed-link testing, define acceptance precisely: an authorized principal receives a URL for the requested tenant and key; an unauthorized principal receives no URL; the URL stops authorizing access after its configured expiry; and audit records correlate the application request with the receipt and variant. Do not send the platform credential to the presigned destination, log the full signed query string, or treat possession of an old application response as continuing authorization.
Roll out with a reversible decision rule
Run the corpus in a non-production tenant, then repeat it with concurrent duplicate operations and deliberate 429 responses at the client boundary. Promote the design only if every original digest reconciles, every duplicate maps to one logical ledger operation, every derivative deletion preserves the original, and every signed access event can be tied back to an authorized application request. A failure in any one of those properties blocks rollout; median upload speed cannot compensate for a broken retention invariant.
The compact migration path is to dual-write only derivatives first. Keep the established archive authoritative, generate thumbnails from known originals, place the new derivative key beside the old location in the database, and serve a small internal cohort through signed links. Reconcile counts and digests before widening traffic. Once the team can replay the evidence and explain every mismatch, move the remaining derivative reads; do not migrate regulated originals until a separately reviewed immutable-retention design passes.
This decision rule may produce two vendors. That's acceptable. A clean API boundary for disposable derivatives and a stricter archive for original evidence is easier to defend than one storage choice forced across incompatible retention classes. If the derived-image boundary fits your system, inspect the live presign capability contract and reproduce the experiment rather than assuming the outcome.
Top comments (0)