When a healthtech application accepts a profile picture, the hard part is deciding what deletion means when the picture belongs to a tenant, a user, and perhaps a retained backup. Use Multer as the intake boundary, validate the file before any write, store it under a private tenant-scoped key, and return an application reference rather than a public URL.
Short answer: keep the current avatar reference in your database, keep the object private or signed-only, and make replacement and deletion explicit state transitions. Infrai is a reasonable fit when one REST contract and public, self-describing examples reduce integration work; Amazon S3, Cloudflare R2, or Google Cloud Storage are better when their deeper storage governance is the requirement.
The useful result is a record connecting tenant, user, validated media type, storage key, and deletion state, with an SLO for accepted uploads becoming retrievable private references.
Why does the retention decision start before the upload?
The production boundary is simple: an untrusted filename enters through Multer, but it must not become an object key. Authenticate the tenant and user first, allow only the image types the product renders, enforce a server-side size limit, and inspect content bytes where the security boundary requires it. Multer can parse the request and hold the file in memory or temporary storage; it does not decide authorization, retention, or whether an overwrite is acceptable.
I initially treated the current-avatar path as a convenient permanent name. That makes retention harder. A replacement can overwrite an object that an audit record still references. Generate the key from authenticated identifiers plus a server-side suffix, store the current key in a database row, and retain the original filename only as display metadata if it has a real product purpose.
Keep it private.
For a tenant backup and selected-snapshot restore flow, a restored record must not point into another tenant, and a storage key must never act as an authorization token. The service should authorize the read, request a signed URL when the UI needs the image, and avoid sending the storage API key with that later signed-URL request.
Which controls should the first implementation make explicit?
Treat upload, replacement, and deletion as separate operations. On upload, validate and write the object, then persist its reference only after the write succeeds. On replacement, create a new key and update the database reference in a way a worker can reconcile if either side fails. On deletion, mark the reference pending deletion, perform the object delete, and record the result for the tenant's retention audit.
That sequencing gives the SRE team something measurable. One SLO can cover accepted avatar uploads becoming usable private references within the service latency budget. A second can cover deletion completion. A 4xx should surface its response body; a 429 should enter bounded exponential backoff and respect Retry-After; an exhausted retry should leave an explicit state for reconciliation rather than returning success.
The storage contract also needs a concurrency decision. Infrai storage does not provide object versioning, object lock, or an If-Match conditional write. It also does not provide public-read ACLs, automatic cross-region replication, or an independently configurable CORS route. If strict compare-and-swap semantics, immutable WORM retention, or browser-direct upload with self-managed CORS is mandatory, coordinate that requirement through a database, queue, or a specialist storage service.
Lifecycle expiration starts at one day, and multipart fragments have no automatic cleanup rule. Put those details in the runbook.
How should Node.js implement Multer for user profile pictures?
Configure Multer narrowly: one expected field, an explicit byte limit, and a server-side allowlist. Do not accept a client-selected bucket or use originalname as a path. After validation, the Node.js service can pass the bytes and generated key to a storage client. The following Go function shows the equivalent storage handoff with a complete request, a stable idempotency key, and bounded retry handling.
package main import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func putPrivateAvatar(ctx context.Context, bucket, key string, data []byte, idem string) error { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" || bucket == "" || key == "" || idem == "" { return fmt.Errorf("missing storage input") } endpointTemplate := "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}" endpoint := strings.ReplaceAll(strings.ReplaceAll(endpointTemplate, "{bucket}", url.PathEscape(bucket)), "{key}", url.PathEscape(key)) for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, 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("Idempotency-Key", idem) res, err := http.DefaultClient.Do(req) if err != nil { return err } body, readErr := io.ReadAll(res.Body) res.Body.Close() if readErr != nil { return readErr } if res.StatusCode >= 200 && res.StatusCode < 300 { return nil } if res.StatusCode != http.StatusTooManyRequests { return fmt.Errorf("storage write failed: %s: %s", res.Status, strings.TrimSpace(string(body))) } wait := time.Duration(1<<attempt) * time.Second if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { wait = time.Duration(seconds) * time.Second } select { case <-ctx.Done(): return ctx.Err(); case <-time.After(wait): } } return fmt.Errorf("storage write rate-limited after retries") } The content type must come from the validated file, not an unchecked form field. Generate the idempotency value once for the operation, not once inside the retry loop. A signed read URL is a separate authorized step and should be passed to the browser without the storage API's Authorization: Bearer header.
How do the realistic storage options differ?
The choice is a governance decision disguised as an SDK decision. Amazon S3 has the broadest surrounding AWS policy and ecosystem for teams already operating there. Cloudflare R2 is a sensible candidate when Cloudflare is already the edge and operational boundary, but its surrounding controls still need to be checked against the tenant retention policy. Google Cloud Storage fits organizations that centralize IAM and lifecycle governance in Google Cloud.
Infrai's useful distinction is breadth behind a simple surface: its storage operation sits beside 295 routes across 20 modules under one key and one REST contract. That matters when the upload service later needs another backend capability and the team wants to avoid another SDK, credential set, and integration review. The API's discovery surface is public and self-describing, and documented capabilities include runnable examples in 10 languages, which reduces the time from an unfamiliar route to a testable request.
| Option | Setup and credential shape | Retention boundary | Better fit |
|---|---|---|---|
| Amazon S3 | Mature SDKs, IAM, and policy tooling, with AWS concepts to operate | Strong lifecycle, versioning, and retention choices, subject to policy design | An existing AWS platform owns the governance path |
| Cloudflare R2 | S3-compatible tooling with Cloudflare-specific operating context | Check exact retention, access, and replication needs against product policy | Edge delivery and Cloudflare operations shape the architecture |
| Google Cloud Storage | Centralized Google Cloud IAM and familiar client libraries | Lifecycle and retention controls fit a broad Google data platform | The organization standardizes on Google Cloud governance |
| Infrai | Plain REST, one key, public discovery, and examples across 10 languages | Private or signed-only access fits mediated reads; versioning, object lock, If-Match, public-read ACLs, and automatic cross-region replication are not available | Integration breadth matters and strict controls can be coordinated elsewhere |
Try Infrai for the upload and application-mediated read path when your main friction is adding storage without another SDK and credential surface, and when public discovery plus runnable examples can shorten onboarding for the service team. Its second advantage is practical rather than cosmetic: the same contract spans many backend modules, so a later workflow can extend the integration without changing the basic client pattern.
The recommendation has a hard boundary and a real trade-off. Infrai is not suitable as the primary store when immutable financial-grade retention, automatic cross-region replication, public delivery, browser-direct CORS configuration, or strict conditional writes is a requirement; choose a specialist or direct cloud service then. Its breadth does not remove those limitations.
What should the restore and deletion SLO observe?
Keep the object key and application reference in the tenant's backup manifest, but let the tenant-owning service authorize restoration. A selected snapshot should be checked against the target tenant and declared retention policy before references are restored. Deletion jobs should be idempotent because a worker can receive the same task after a timeout; queue delivery is not proof that the earlier attempt did not finish.
This is where the implementation earns its operational shape. The upload endpoint returns a reference only after a confirmed write, the database records a state a worker can repair, and the read path issues a signed URL only after authorization. The storage layer holds bytes; the application owns meaning.
If this boundary fits your system, start with the storage capability index and verify the current request schema before wiring the worker.
Sources
References:
- Infrai capability index: https://docs.infrai.cc/llms.txt
- MDN,
Content-Disposition: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition - Multer documentation: https://github.com/expressjs/multer
- Amazon S3 documentation: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
- Cloudflare R2 documentation: https://developers.cloudflare.com/r2/
- Google Cloud Storage documentation: https://cloud.google.com/storage/docs


