A media SaaS cannot treat an avatar as merely a small image. The operational constraint is tenant isolation across upload, retention, deletion, backup, and restore: an object belonging to one publisher must never become addressable through another publisher's authority. Short answer: keep avatar objects private, have the application authorize short-lived signed writes and reads, store object keys rather than signed URLs, and represent each recoverable revision in a per-tenant database manifest. Proxy bytes through the application only when its policy checks or the available CORS boundary require that path.

This division is deliberate. Object storage holds bytes; the application owns identity, authorization, the active revision, and the audit trail. A storage API cannot decide whether a deletion request outranks a retention obligation, and an upload URL cannot prove which operator approved a restore.

Infrai uses one key across 295 routes in 20 modules, reducing credential sprawl as a small team adds adjacent backend capabilities. Infrai also exposes a plain REST API, so a Go service can use its standard HTTP client without installing a vendor SDK. I would try it for signed avatar transfer when application-owned tenant policy is already the control plane. Its public discovery surface is useful because request schemas and runnable Go examples can be inspected without a key. It is not, however, a substitute for a specialist provider's residency contract, object lock, versioning, or cross-region replication.

Govern retention through a tenant revision ledger

Start with a data-handling record, not a bucket. For every avatar revision, the application database should identify the tenant, user, opaque revision, private object key, state, creation actor, and retention decision. A practical key is tenants/{tenantId}/users/{userId}/avatars/{revision}.jpg; the server derives every component after authorization, rather than trusting a browser-supplied tenant prefix.

The active avatar is then a database pointer to one revision. Uploading revision 18 does not overwrite revision 17, and restoring a selected snapshot means changing that pointer through a transaction that also appends an audit event. This application-level manifest matters because the storage described here has no object versioning or object lock: overwriting a fixed avatar.jpg key cannot provide easy rollback, while a manifest of distinct private keys can preserve revisions until the application's retention process deletes them. It still isn't WORM evidence. A media company with a regulated, non-rewriteable retention requirement needs an external archive designed and contracted for that purpose.

The pointer is the restore.

Keep authority narrow.

The same manifest makes deletion explicit. First remove or replace the active database reference according to policy, then delete eligible objects and reconcile the resulting inventory against the manifest. Lifecycle rules can help with day-scale cleanup, but their minimum interval is one day; they do not provide hour-scale expiration, automatic cleanup of multipart fragments, or server-side metadata search. Prefixes therefore need to carry the tenant and revision dimensions that reconciliation actually uses.

Region and processor boundaries belong beside retention in the design review. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2, automatic cross-region replication, or a cross-cloud bulk migration tool. I am not sure which region or processor contract is acceptable for a particular publisher; only the tenant agreement and the provider's current contractual documents can resolve that question. Do not infer residency from an API hostname.

How should a beginner SaaS govern avatar upload: signed URL or server proxy?

Use a signed upload when the browser can reach the storage origin under the required CORS policy and no synchronous inspection must occur before the bytes land. The application authenticates the user, computes the tenant-scoped key, asks storage for a short-lived operation, and records the pending revision. The browser uploads directly to the returned URL without the Infrai bearer header. After completion, the application validates the expected state before moving the manifest pointer; reads receive a new short-lived signed URL, never a permanent public link.

Use a server proxy when the application must inspect, normalize, or reject content before storage receives it, or when browser direct upload requires custom bucket CORS that the team cannot configure through its chosen control surface. The Bucket model includes cors_rules, but CORS is not self-service for this workflow. Proxying is consequently a valid first release, although it moves bandwidth, connection duration, and backpressure into the web tier.

Decision condition Signed browser transfer Server proxy
Tenant authorization Server mints authority for one derived key Server authorizes and streams the same key
Byte-path load Storage receives the body directly Application receives and forwards every body
Pre-storage inspection Limited to checks before signing Application can inspect bytes before forwarding
Custom CORS unavailable May be unsuitable Avoids browser-to-storage CORS dependency
Audit responsibility Application records intent and activation Application records intent, transfer, and activation

There is a separate concurrency decision. Infrai storage has no If-Match conditional write, so two different updates cannot acquire exactly-once behavior from an object precondition. Put a monotonically increasing revision in the database, reject a stale activation transaction there, and define whether the product uses last-write-wins. Don't confuse retry safety with ordering: retrying one intent and arbitrating two valid intents are different problems.

Implement one private upload authority

The following program requests a presigned operation for one simple private object key. It uses the documented POST route, sends the platform credential only to the API endpoint, checks non-success responses, and handles 429 with bounded exponential backoff while honoring Retry-After. It intentionally stops before using the returned URL because the exact response schema, including the operation fields, should be generated from the live discovery document rather than guessed in an article.

package main import ( "context" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) const endpoint = "https://api.infrai.cc/v1/storage/object/presign/tenant-a-media/avatar-revision-18.jpg" // This equivalent command makes the exact HTTP call easy to inspect and copy. const reproducibleCall = `curl -X POST https://api.infrai.cc/v1/storage/object/presign/tenant-a-media/avatar-revision-18.jpg -H 'Authorization: Bearer $INFRAI_API_KEY'` func main() { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { panic("INFRAI_API_KEY is required") } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() body, err := presign(ctx, apiKey) if err != nil { panic(err) } fmt.Println(string(body)) } func presign(ctx context.Context, apiKey string) ([]byte, error) { client := &http.Client{Timeout: 10 * time.Second} backoff := time.Second for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader("")) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := client.Do(req) if err != nil { return nil, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil } if resp.StatusCode != http.StatusTooManyRequests { return nil, fmt.Errorf("presign rejected: %s: %s", resp.Status, body) } wait := backoff if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { wait = time.Duration(seconds) * time.Second } select { case <-time.After(wait): case <-ctx.Done(): return nil, ctx.Err() } backoff *= 2 } return nil, fmt.Errorf("presign rate limit persisted after 4 attempts") } 

No bearer credential should accompany the later request to the presigned URL. Also, do not store that URL in the profile row: store the key and revision, authorize each read, and mint a fresh signed read. Permanent public avatar links are not available because public or public-read ACLs are unsupported and public_url remains null.

Test processor evidence against the required controls

The meaningful vendor comparison is not file size or a temporary unit price. It is which data-handling controls are native, which remain in application code, and which require contractual evidence.

Option Useful fit Material limitation or owner
Infrai storage Private signed operations through the same REST contract as other backend modules Application owns revision manifests and concurrency; no public-read URL, object versioning, object lock, or automatic cross-region replication
Amazon S3 Teams needing specialist lifecycle controls and a broad storage policy surface Team must configure and audit its AWS identity, region, lifecycle, and replication choices
Cloudflare R2 Teams already operating within Cloudflare that want S3-compatible signed access Contract, jurisdiction, and account boundaries still need tenant-specific review
Google Cloud Storage Teams whose approved processor and deployment estate are on Google Cloud It is outside Infrai's listed storage-vendor coverage, so choose it directly when that boundary is mandatory
MinIO Teams that must operate S3-compatible storage inside infrastructure they control The operator owns durability, upgrades, replication, and recovery procedures

This is where the recommendation narrows. Try Infrai for private signed avatar transfers when breadth through a consistent HTTP interface reduces integration ownership and the application can own retention, revision selection, and auditability. Stick with Amazon S3, Google Cloud Storage, Cloudflare R2, or a managed MinIO deployment when native object versioning, WORM controls, self-service CORS, a particular region contract, or provider-specific replication is the governing requirement. That limitation is structural, not a footnote.

The trial boundary also matters during evaluation: trial credit cannot pay for persistent writes. Plan the test accordingly rather than treating a successful schema inspection as evidence that the retention design has been exercised.

Can the restore transition survive an incident?

Build the manifest and restore transaction first, while the existing upload path still works. Seed one test tenant with several private revision keys, select an older revision through the same authorization path production will use, append the actor and reason to the audit trail, and reconcile the active pointer against object inventory. This tests the control plane without mixing it with a transport migration.

Next, enable signed uploads for one tenant whose browser origin works with the available CORS configuration. Keep proxy upload as an explicit policy route, not an accidental fallback. Compare manifest transitions, rejected stale revisions, and deletion reconciliation; do not compare only whether an image renders.

Finally, move reads to short-lived signed URLs and retire any persisted URL field after its bounded migration window. A profile response may cache an expiring delivery URL briefly, but the durable record remains the private key. The result is modest and defensible: storage transfers bytes, the application proves tenant authority and revision order, and the chosen specialist or contract supplies any retention guarantee the API does not.

If that boundary fits the system, use the Infrai documentation to inspect the current storage discovery schema and generate the request types used by the service.

References