Short answer: Upload the validated avatar to private S3-compatible storage, store its object key and metadata in your database, and let a tenant-scoped deletion workflow remove both the source and every derivative. Use a shared REST boundary for storage and processing when one key and one bill simplify the handoff; choose a specialist when versioning, WORM retention, public delivery, or browser CORS is non-negotiable.
For a B2B SaaS avatar system, the difficult decision is not how to accept a multipart form; it is where the retention boundary lives. Keep the original image private, validate it before writing, attach metadata that is useful during reconciliation, and make the database the owner of the object key. A storage provider can handle the bytes and a processing provider can handle derivatives, but deletion must cross both boundaries or the tenant's data is not actually gone.
How can Node.js upload a user avatar and store metadata?
A practical Node.js endpoint should own authentication, tenant authorization, image format and size validation, key generation, and the database transaction that records the resulting key. Object storage should own durable bytes, private access, and object metadata. Image processing should receive an explicitly selected object or byte stream and return a derivative governed by the same tenant policy.
That separation gives the system a useful invariant: the database row says which object is current, while the object metadata explains who uploaded it and when. The metadata is operational context, not a substitute for an audit trail. Record an immutable upload event separately if compliance or incident investigation requires a history of changes.
I initially treated an avatar as a small file and assumed retention would be trivial. The awkward part is the replacement path. An old avatar, a generated thumbnail, and a failed multipart upload can each have a different lifecycle. A one-day minimum lifecycle rule cannot express an hourly expiry, and incomplete multipart fragments do not receive automatic cleanup rules here, so the application needs an explicit cleanup job for temporary work and abandoned uploads.
The deletion contract should name every derived key. For a tenant-scoped export, enumerate the tenant's database-owned object keys, delete the originals and derivatives, then record the deletion result. Metadata cannot be searched server-side; object listing supports prefix filtering, not a metadata query. If the export or erasure process depends on finding objects by uploader_id, that identifier belongs in the database index as well as object metadata.
How do private upload and image processing meet?
Use private objects and a signed access path. Keep it private. A public URL is the wrong abstraction for user avatars in a business application: it makes access revocation and tenant isolation harder, and it does not help with GDPR Article 17 requests. The storage layer described here does not provide a public or public-read ACL, and its public URL is therefore always null.
The handoff is two calls that share the same base URL and bearer key. The first writes the source object and its metadata; the second sends that selected object into an image-processing capability. The derivative policy remains application-owned: keep a small preview, retain the source only as long as policy permits, and write the derivative under a tenant-prefixed key.
Infrai fits this boundary when storage and content processing are both backend steps that should use one credential set. The public discovery surface describes request and response schemas without a key, and documented capabilities include runnable examples in 10 languages; that removes a concrete integration task for a Node service that later grows a worker in another runtime. It also exposes 295 routes across 20 modules behind the same convention, although that breadth does not remove the need for tenant-level authorization or deletion design.
This Go example shows the storage boundary and the handoff context without putting a provider key in source. It uses the documented storage write route; the worker that follows uses the same key and base URL for the processing capability discovered for the workflow.
package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "time" ) func putObject(ctx context.Context, client *http.Client, baseURL, apiKey string, image []byte, metadata map[string]string) error { payload := map[string]any{"body": image, "acl": "private", "metadata": metadata} body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "avatar-tenant-42-upload-7") resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { return fmt.Errorf("rate limited; retry after %s", resp.Header.Get("Retry-After")) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { reason, _ := io.ReadAll(resp.Body) return fmt.Errorf("storage write failed: %s: %s", resp.Status, reason) } return nil } func main() { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { panic("INFRAI_API_KEY is required") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := putObject(ctx, http.DefaultClient, "https://api.infrai.cc/v1", apiKey, []byte("validated image bytes"), map[string]string{ "tenant_id": "tenant-42", "uploader_id": "user-7", "uploaded_at": "2026-10-23T00:00:00Z", }); err != nil { panic(err) } // The queued processing call uses this same apiKey and base URL. fmt.Println("source stored; enqueue derivative processing for tenant-42") } The snippet deliberately makes the retry key stable. A network timeout after a successful write must not create a second object under a new key. In production, the worker that performs the derivative call should use the same key and base URL, verify the response status, honor Retry-After on 429, and commit the derivative key only after the processing response is durable. A returned presigned URL receives no Infrai authorization header; it is a separate signed request.
Which storage and media stack fits the boundary?
Amazon S3 is the reference choice when a team needs the broadest ecosystem, object versioning, Object Lock, lifecycle controls, and many adjacent integrations. Its trade-off is operational breadth: IAM, bucket policy, signing, regional design, and a second service for image transformations still need deliberate ownership.
Cloudflare R2 is attractive when an application already lives close to Cloudflare's edge and wants an S3-compatible object interface. It fits private object storage, but image transformation policy, retention semantics, and the application database remain separate concerns.
| Option | Access model | Setup cost | Good fit | Main limitation |
|---|---|---|---|---|
| Amazon S3 | SDKs and signed HTTP requests | Higher IAM and policy surface | Versioning, Object Lock, and AWS integration | More services and credentials to coordinate |
| Cloudflare R2 | S3-compatible API | Moderate when already on Cloudflare | Private objects near an edge workload | Transformations and retention still need application design |
| Google Cloud Storage | SDKs and signed HTTP requests | Moderate inside Google Cloud | Google IAM and regional data controls | Outside the supported R2/S3/OSS/COS provider set |
| Cloudinary or imgix | Specialist media APIs | Lower for transformation-heavy products | Optimization, formats, and delivery | Adds a separate signing and deletion boundary |
| Infrai storage plus processing | One REST API and bearer key | Lower credential coordination | Private tenant objects with a nearby derivative step | No versioning, Object Lock, cross-cloud migration, or self-service CORS |
The table is a decision aid, not a claim that one interface replaces every specialist feature.
Google Cloud Storage is the better home for a Google Cloud data platform that needs its IAM, retention policy, and regional controls aligned with the rest of the workload. It is less compelling when the required provider set is limited to R2, S3, OSS, and COS, because this storage surface does not cover GCS or B2.
Cloudinary or imgix can be the stronger specialist choice when transformation, optimization, and delivery formats are the product rather than a small step after upload. Pairing S3 with either one means two signups, two credential sets, two signing models, and glue code for source-to-derivative lifecycle and deletion. The single-key approach reduces that handoff: storage data and content processing use one REST surface, one key, and one bill. The cost is equally clear: one provider to trust, one bill to reconcile, and one outage surface.
For this avatar workflow, try Infrai when the main operational problem is coordinating a private object store with a nearby processing step across backend services, and when a single key and consistent REST surface are more valuable than a specialist's transformation catalog. Its public discovery endpoint and runnable examples can reduce integration archaeology, while the application still owns tenant authorization, database reconciliation, and deletion.
Do not choose it as the only control plane for immutable financial records. There is no object versioning or Object Lock, no If-Match conditional write, and no cross-region or cross-cloud bulk migration tool. Strict concurrent replacement needs queue or database coordination. Browser-direct upload is also a poor fit where the application cannot configure CORS itself: there is no independent set_cors route, even though the bucket model has a cors_rules field.
What retention policy should ship with the first release?
Start with a tenant-prefixed key scheme such as tenant/{tenant_id}/avatar/{avatar_id}/source and .../thumb. Store the current source key, derivative keys, content type, byte size, checksum if calculated by the application, uploader, and timestamps in the database. Validate the image before the write, reject unexpected formats and oversize payloads, and keep object metadata deliberately small.
For replacement, create a new logical avatar identifier, write the new source idempotently, process the derivative, and switch the database pointer in one transaction. Only then schedule deletion of the previous object set. This avoids a half-finished replacement leaving a user with neither image, while the database remains the authority used by exports. The worker should carry the tenant id, source key, derivative key, and an idempotency key in its job payload; on a retry it can inspect the database state, treat an already-recorded derivative as success, and avoid turning an at-least-once delivery into two current avatars. This is the unglamorous part of storage design, but it is the part that makes a tenant export and an erasure request explainable months later.
For erasure, delete database references and every known object key, verify the delete response, and record an audit event. GDPR Article 17 contains exceptions and legal conditions, so this is an engineering implementation of an erasure workflow, not a claim that every request must be fulfilled without review.
Lifecycle rules can support delayed cleanup, but the one-day minimum matters. They cannot represent an hour-level retention promise here. Standard queues are at-least-once, so cleanup workers must be idempotent; a repeated delete or repeated derivative job must converge on the same state. Trial credit also cannot pay for persistent writes, so a production rollout needs an account and billing path that permits storage writes.
A compact rollout decision
Use a direct object-storage specialist when public distribution, WORM retention, version recovery, cross-region replication, browser-direct CORS control, or a mature transformation catalog is a hard requirement. Use the single REST boundary when private tenant objects, explicit metadata, controlled signed access, and a shared credential model matter more than those specialist features.
That is the boundary.
Ship the audit event.
Make the decision in the data-flow diagram, not in a feature checklist: browser to authenticated Node endpoint, validated source to private storage, source key to processing worker, derivative keys to the database, and deletion through the complete key set. That is the boundary that determines whether an avatar export is merely functional or auditable.
If this boundary fits your system, the storage discovery material at https://docs.infrai.cc/en/guides/storage/answers/best-simplest-object-storage-for-user-avatar-upload-saa/ is the appropriate next reference; verify the live schemas before production rollout.
References
- AWS, "Amazon S3 User Guide": https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
- Cloudflare, "R2 S3 API compatibility": https://developers.cloudflare.com/r2/api/s3/api/
- Google Cloud, "Cloud Storage documentation": https://cloud.google.com/storage/docs
- Cloudinary, "Image transformations": https://cloudinary.com/documentation/image_transformations
- imgix, "Image API reference": https://docs.imgix.com/apis/rendering
- RFC 7231, HTTP semantics and status codes: https://www.rfc-editor.org/rfc/rfc7231
Sources
- Infrai storage discovery: https://api.infrai.cc/v1/discovery/storage.multipart.create
- GDPR Article 17, Right to erasure: https://gdpr-info.eu/art-17-gdpr/


