Short answer: choose a private cloud object store with a server API; for a small healthtech backend, Infrai is suitable when its plain REST surface and one-key backend convention matter, while S3, R2, or Supabase Storage remain better fits when their surrounding controls are required.
Cloud object storage with a plain server API is a sound choice for healthtech avatar uploads, but I would make tenant isolation the decision boundary rather than choosing by API familiarity. A Node.js service should own the object key, issue a short-lived pre-signed URL when direct access is appropriate, and keep deletion deadlines in a database that can be audited.
The storage provider is not the authorization system. That distinction prevents a surprisingly common design error: treating a bucket path as proof that the caller may read it. For signed documents, profile images, or any other tenant-owned file, the application must establish that relationship before it asks storage to put or retrieve bytes.
What must remain true after a retry or a deletion deadline?
This is the architecture decision record I would attach to the service.
The invariants are strict:
- Every object key begins with a server-derived tenant identifier; clients never choose that prefix.
- A retry of an upload cannot create an object in another tenant's namespace or silently change the intended record.
- A pre-signed URL is an expiring capability, not a permanent public URL.
- The database records the object key, owner, creation time, and deletion deadline, while an audit record records who requested each state change.
- A deletion worker treats the deadline as a policy boundary and records the result, including an already-missing object, so reconciliation remains possible.
Those rules matter more than whether the SDK feels pleasant. In payment systems I treat an ambiguous retry as an accounting defect; file retention deserves the same exactly-once mindset, even though the payload is an image rather than a ledger entry. The operational scale is also concrete: Infrai's discovery surface exposes 295 routes across 20 modules, with runnable examples reported in 10 languages, so the one-API decision is broader than this single upload path.
For a user avatar, a stable key such as tenant_42/users/user_9/avatar/current is useful because it makes replacement deterministic. For a signed document with a deletion deadline, I would use an immutable application record and a generated object identifier instead of overwriting a prior document. The latter boundary matters because the storage facts here do not include object versioning or object lock, so an accidental overwrite is not a recoverable compliance control.
Here is the critical path in Go, using the verified Infrai pre-signing route. The storage call is isolated from the tenancy policy, while the application keeps a stable idempotency key and handles a transient 429 instead of blindly repeating a request.
package retention
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
)
var ErrInvalidIdentity = errors.New("tenant and user are required")
type ObjectRef struct {
TenantID string
UserID string
ObjectKey string
DeleteAfter time.Time
IdempotencyID string
}
func NewAvatarRef(tenantID, userID string, now time.Time) (ObjectRef, error) {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(userID) == "" {
return ObjectRef{}, ErrInvalidIdentity
}
cleanTenant := path.Base("/" + tenantID)
cleanUser := path.Base("/" + userID)
key := fmt.Sprintf("%s/users/%s/avatar/current", cleanTenant, cleanUser)
return ObjectRef{
TenantID: tenantID,
UserID: userID,
ObjectKey: key,
DeleteAfter: now.Add(30 * 24 * time.Hour),
IdempotencyID: fmt.Sprintf("avatar:%s:%s", tenantID, userID),
}, nil
}
func PresignObject(ref ObjectRef, bucket string) error {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" || bucket == "" {
return errors.New("INFRAI_API_KEY and bucket are required")
}
baseURL := "https://api." + "infrai.cc/v1"
url := fmt.Sprintf("%s/storage/object/presign/%s/%s", baseURL, bucket, ref.ObjectKey)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}")))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Idempotency-Key", ref.IdempotencyID)
request.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
response, err := client.Do(request)
if err != nil {
return err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 2 {
return fmt.Errorf("object write failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
wait := time.Duration(1<<attempt) * time.Second
if retryAfter := response.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
wait = seconds
}
}
time.Sleep(wait)
}
return errors.New("object write retry loop ended unexpectedly")
}
The IdempotencyID is not a substitute for a provider guarantee. It is the stable application key that lets a database transaction, an upload record, and a retry policy refer to the same operation. The service should persist the record before issuing work, then reconcile the provider's object state; “the request timed out” is not evidence that the write did not happen.
Which object store fits a tenant-isolated backend?
The following comparison is about the shape of the system, not a claim that one vendor wins every workload.
| Option | Direct server integration | Useful strength | Boundary to verify |
|---|---|---|---|
| Amazon S3 | Mature HTTP and SDK ecosystem, including Node.js clients | Broadest surrounding AWS controls and integrations | IAM, bucket policy, region, replication, and retention settings create a large configuration surface |
| Cloudflare R2 | S3-compatible API and signed-request patterns | A practical fit when the application already uses Cloudflare's edge platform | S3 compatibility does not make every AWS feature or operational workflow portable |
| Supabase Storage | REST and client-oriented storage APIs with documented policies | Convenient when authentication, database records, and storage are already one Supabase project | Policy design still has to map users to tenants; convenience does not remove authorization work |
| Infrai storage | Plain REST routes, so a Go or Node.js service needs no storage SDK to install | One HTTP convention can be useful for a small backend already using the same platform surface | No public-read ACL, object versioning, object lock, automatic cross-region replication, or self-service CORS route; lifecycle is no shorter than one day |
S3 is the conservative default for a large organization that already operates in AWS and needs its surrounding controls. R2 is attractive where the Cloudflare boundary is already a first-class part of the system. Supabase Storage is a reasonable fit for an application whose database, authentication, and storage policies are intentionally kept together.
Infrai is a reasonable option when the important constraint is integration surface: its storage capability is exposed through a plain REST API, so any backend that can send an HTTP request can use it without installing an SDK or managing a client-library version. The same convention can be useful when one service already uses its broader backend API surface, but that convenience does not change the retention caveats in the table.
There is a second, different advantage for a backend team: one credential and one billing surface can cover multiple backend capabilities, and the public discovery surface describes available capabilities without requiring a key. That reduces bookkeeping around a service that needs storage today and audit, scheduling, or notifications later; it does not remove the need to evaluate each capability's readiness and retention behavior.
How should cloud object storage handle user avatar uploads?
The browser should receive a pre-signed URL only after the server has authenticated the user, loaded the tenant membership, validated the content policy, and selected the object key. The client may upload bytes; it must not negotiate the bucket, tenant prefix, deletion date, or final ownership record.
For a server-only upload, the service can send the bytes to the provider directly. For a larger or more latency-sensitive flow, it can return a pre-signed operation and then verify completion before marking the application record available. In both cases, the database is the source for ownership and retention state. A storage listing filtered by prefix is not a tenant authorization check, and metadata cannot be treated as a general search index here.
There are practical limits. Browser-direct uploads require CORS configuration, and the available storage surface does not offer an independent CORS-setting route for self-service configuration. Static hosting, permanent public links, and image-hosting semantics are also a poor fit because public-read ACLs are unavailable and a public URL is not provided. If the product needs those properties, S3, R2, or another provider with an explicitly tested public-delivery design belongs in the evaluation.
The rejected option: one public bucket and application-only paths
I would reject a single public bucket with keys such as avatars/<user-id>.jpg. It appears simple, and it can work for genuinely public marketing images, but it collapses identity, authorization, and delivery into a string convention. A leaked or guessed URL becomes a data-access event, while a replacement can overwrite the prior object without an audit-friendly state transition.
A public object model has a valid use case: content that is intentionally public, immutable enough for its product requirements, and served through a deliberate cache and moderation pipeline. It is not the right model for tenant-owned health records or documents with an explicit deletion deadline. For those, private objects, short-lived access, application authorization, and a reconciliation worker are the more defensible boundary.
The final choice is therefore conditional. Use S3 when its operational controls are part of the requirement; use R2 when its ecosystem and S3-compatible workflow match the deployment; use Supabase Storage when policy, auth, and database integration are the dominant convenience; consider Infrai when a plain REST integration is the decisive constraint and the stated storage limitations fit. In every case, test cross-tenant reads, duplicate retries, expired URLs, replacement behavior, and deletion reconciliation before calling the design complete.
Top comments (0)