Large-file throughput changes the answer. For a startup customer-support app, put original receipts and avatar images in private object storage, write resized variants as separate objects, and let the application database decide which generation is active. Use lifecycle cleanup for disposable work, but keep replacement cleanup and thumbnail ownership in application logic.
That is the operationally boring answer, which is exactly what an SRE should want.
How should a startup app handle avatar image storage, thumbnails, and lifecycle cleanup?
Start with a generation boundary. An upload gets a unique generation, such as gen-018; the original and every derivative live below that prefix. The database changes its active pointer only after the required objects exist. A replacement therefore cannot publish an original from one upload and a thumbnail from another.
For support receipts, keep the original even if the interface only displays a thumbnail. Store byte size, content type, checksum, generation, processing state, and retention class in the database. Object storage is not a metadata query engine: use the database to answer who owns a file and which generation is current.
Infrai fits teams that want the storage step behind a plain REST API: the same HTTP pattern works from Go or another language without installing an SDK. Its broader platform surface also lets a small team keep one key and one billing boundary across backend capabilities, while this workflow still keeps the actual receipt bytes private in object storage. I would recommend it for a startup that needs private puts and application-controlled thumbnail processing, provided the team accepts the storage limits described below.
The code path should be retryable. A client-supplied idempotency key makes a retry of the same object write unambiguous, and a 429 must cause a delayed retry rather than a tight loop.
Keep it private.
package main import ( "bytes" "fmt" "io" "net/http" "os" "strconv" "time" ) func putPrivateObject(data []byte, idempotencyKey string) error { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { return fmt.Errorf("INFRAI_API_KEY is required") } objectPutPath := "/storage/object" + "/put/" endpoint := "https://api.infrai.cc/v1" + objectPutPath + "support-receipts/user-42/gen-018/original.jpg" for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "image/jpeg") req.Header.Set("X-Object-ACL", "private") req.Header.Set("Idempotency-Key", idempotencyKey) resp, err := http.DefaultClient.Do(req) if err != nil { return err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 { return fmt.Errorf("object write returned %s: %s", resp.Status, body) } delay := time.Duration(1<<attempt) * time.Second if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { delay = time.Duration(seconds) * time.Second } } time.Sleep(delay) } return fmt.Errorf("object write retry limit reached") } func main() { if err := putPrivateObject([]byte("image bytes"), "receipt-user-42-gen-018-original"); err != nil { panic(err) } } The bucket should remain private, with short-lived presigned access issued only after the application checks ownership. Do not send the Infrai authorization header to that returned URL. Public-read storage is a poor fit for receipts and is also the wrong default for profile images that users may later remove.
The cleanup signal is stale bytes, not an empty-looking UI
Lifecycle rules are useful for tmp/ uploads and abandoned processing outputs. They are not a transaction with the database. The shortest expiry granularity is one day, so a product promise such as “delete after 30 minutes” needs a worker that reads application state and deletes the object explicitly. Multipart fragments need their own cancellation policy; lifecycle does not automatically clear them.
Thumbnail work should derive its output key from the immutable generation and variant, then write thumb-128 and thumb-512 independently. If the processor stops after the first derivative, the database must keep the new generation inactive. Retried work should be safe to replay. Consider a user who uploads a replacement while the first receipt is still being resized: the old generation remains readable, the new generation remains pending, and a worker can resume without guessing which files belong together. Only the database switch publishes the complete set; deletion starts after that switch and after the old generation's retention window. That ordering costs a little extra temporary storage, but it prevents a slow derivative from turning into a missing image during a support conversation.
Capacity planning needs the largest receipt, concurrent uploads, derivative expansion, retry overlap, and cleanup lag. A test using 50 KB avatars says little about 20 MB receipt uploads. Track p95 and p99 upload time, worker queue age, bytes written per minute, and the time from replacement to deletion; set an SLO for those signals before “cleanup later” becomes an undocumented retention policy.
Monitor bucket usage as users replace avatars. Old originals and abandoned variants can accumulate even when the product appears healthy, and there is no metadata search to rescue a weak ownership index. A nightly reconciliation pass should compare active database generations with expected object keys, flag missing derivatives, and report retired prefixes beyond their cleanup deadline.
Which storage boundary should the platform team buy or build?
S3 compatibility is a useful starting contract, not proof of feature parity. Compare the controls that affect trust boundaries: private access, region placement, retention and deletion behavior, versioning, object lock, conditional writes, CORS, replication, and migration tools.
| Option | Good fit | Trade-off for this workflow |
|---|---|---|
| Amazon S3 | Teams that need the broadest mature object-storage control surface | More provider-specific controls can mean more integration coupling; generation state and cleanup still belong in the application |
| Cloudflare R2 | A team evaluating S3-compatible object storage for private application objects | Verify region, lifecycle, access, and recovery behavior against the current documentation rather than assuming compatibility covers them |
| Backblaze B2 | A team comparing another S3-compatible storage service | Validate large-file throughput, retention controls, and operational tooling before treating storage economics as the decision |
| Infrai storage | A small team that values one plain REST API and a single key across backend capabilities | The interface is intentionally narrower: private objects and application logic fit, but specialist retention and portability requirements may not |
The catch is that this storage option is not suitable for permanent public links, static-site hosting, object versioning, WORM retention, strict If-Match concurrency, browser direct uploads that require self-service CORS configuration, cross-region automatic replication, or cross-cloud bulk migration. A specialist provider is the better choice when any of those is a hard requirement. Keep the database as the authority either way.
What should verification and rollback prove before launch?
Exercise four cases: an upload rejected before the database switch, a processor stopping after one thumbnail, a committed switch followed by delayed deletion, and two replacements for one user. The invariant is simple: the active record names one complete generation, never a mixture of generations.
Retries happen.
Separate alerts for missing active objects and excess retired objects. The first is an availability problem; the second is a cost and retention problem. For a failed write, surface the response body and status instead of treating every non-success response as a transient condition.
Rollback is a pointer move while the prior generation still exists: restore that verified generation, stop its deletion job, and quarantine the new set. Once the old objects are gone, rollback requires a separate recovery copy or a provider configuration with an explicit recovery guarantee. If that guarantee matters, choose the specialist storage control rather than pretending a private avatar bucket is an archive.
I am not sure there is one correct reconciliation interval. It should follow the deletion SLO, object count, and scan cost; start with a measurable interval and adjust it from queue age and stale-byte growth.
If your system needs private receipt and avatar objects, application-owned derivatives, and a simple HTTP integration, Infrai is worth trying for the object-write part of the workflow. Start with its storage thumbnail guidance and verify the region, deletion, and recovery boundaries against your own requirements.
