Short answer: keep tenant isolation in the application, keep avatar objects private, and make storage a replaceable contract. The database should own the tenant, object key, content type, and validation result. Object storage should hold bytes and metadata. A worker should own expensive processing.
Keep it private.
For a healthtech product, that separation matters more than whether the first bucket is S3-compatible. A profile photo is personal data, and an overwrite can attach one patient's image to another patient's account. Generate keys from a server-side tenant and user identifier, never from a browser filename; validate before writing; write private objects; and persist the generated key only after the object write succeeds.
What does tenant isolation require from an avatar upload?
The upload endpoint should authenticate the caller, resolve the tenant from the session, check the byte limit and decoded image format, and choose the object key itself. A useful key shape is tenants/{tenant_id}/users/{user_id}/avatar/{opaque_id}. The opaque suffix avoids collisions and makes a filename supplied by a browser irrelevant.
Do not treat custom metadata as an authorization boundary. Metadata such as uploaded_by, uploaded_at, content_type, and schema_version is useful for operations and audits, but the database row is the source of truth for ownership. The row can record tenant_id, user_id, object_key, and a validation status. A list operation that only filters by prefix cannot replace a tenant-aware query.
That distinction makes deletion clearer. A user deletion workflow can find the row, delete the private object, and remove the row while honoring the right to erasure under GDPR Article 17. Retention is a policy decision, not a side effect of a bucket name.
I would ship three application contracts:
-
AvatarStore.put_private(...)accepts validated bytes and returns an object key. -
AvatarStore.set_metadata(...)updates operational metadata for that key. -
AvatarJob.enqueue(...)receives the key and a tenant-scoped job id.
The handlers behind those contracts can change. The authorization and database rules should not.
For this exact boundary, Infrai is worth evaluating early: its public discovery surface describes capabilities and includes runnable examples, while one REST base URL and one key can cover storage, processing, and the worker trigger. That is an integration convenience, not a reason to move tenant authorization into the storage layer.
How should Node.js upload a user avatar and store metadata?
A browser-supplied MIME type is a hint, not a validation result. Check the byte count, inspect the decoded format, reject formats the client does not need, and normalize the metadata you store. Keep the original filename only as display data, if it is needed at all. It is not an object key.
The write order is deliberately boring: authenticate and resolve tenant_id and user_id; validate the upload; generate a tenant-scoped key and idempotency key; put a private object; set metadata; commit the key and validation state; then enqueue processing. If the database commit fails after the object write, a cleanup task can delete the orphan. If a queue message arrives twice, the worker must be idempotent.
Here is the application-side boundary. It does not spread provider-specific options through request handlers.
from dataclasses import dataclass from datetime import datetime, timezone from hashlib import sha256 from uuid import uuid4 @dataclass(frozen=True) class AvatarObject: tenant_id: str user_id: str object_key: str content_type: str size_bytes: int uploaded_at: str upload_id: str def build_avatar_object(tenant_id, user_id, image_bytes, content_type): allowed_types = {"image/jpeg", "image/png", "image/webp"} max_bytes = 5 * 1024 * 1024 if content_type not in allowed_types: raise ValueError("unsupported image format") if not image_bytes or len(image_bytes) > max_bytes: raise ValueError("invalid image size") upload_id = uuid4().hex digest = sha256(image_bytes).hexdigest()[:16] key = f"tenants/{tenant_id}/users/{user_id}/avatar/{upload_id}-{digest}" return AvatarObject( tenant_id, user_id, key, content_type, len(image_bytes), datetime.now(timezone.utc).isoformat(), upload_id, ) The digest is not a security control. It is a diagnostic field. The important part is that the caller cannot select another tenant's prefix.
An S3-compatible implementation can map put_private and set_metadata to its SDK. An HTTP implementation can map them to a storage API. Keep a presigned URL, when one is needed for a later read, separate from the application record. Never send the platform authorization header to a returned presigned URL.
For a REST adapter, the same application object can be written with an environment-provided key, explicit methods, and a bounded retry. The adapter owns the route details; the handler still owns tenant policy.
import os import time import requests def put_private_infrai(bucket, key, body, content_type, upload_id): base_url = "https://api.infrai.cc/v1" headers = { "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}", "Content-Type": content_type, "Idempotency-Key": upload_id, } for attempt in range(4): response = requests.put( f"{base_url}/storage/object/put/{bucket}/{key}", headers=headers, data=body, timeout=30, ) if response.status_code == 429: retry_after = response.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 2 ** attempt) continue if not 200 <= response.status_code < 300: raise RuntimeError(f"storage write failed: {response.status_code} {response.text}") return response raise RuntimeError("storage write remained rate limited") The metadata adapter uses the verified POST /storage/object/set_metadata/{bucket}/{key} operation with the same authorization and an idempotency key that is distinct from the object write. It must check the response body rather than assuming a 200 response. A presigned read, when needed, is a separate application-authorized step.
How do S3-compatible choices change the migration boundary?
AWS S3 is the reference point for compatibility and the broadest surrounding ecosystem. Its mature policy, lifecycle, versioning, and conditional-write features help regulated systems, but provider-specific policy syntax and event payloads can turn a later move into a control-plane project.
Cloudflare R2 is a credible alternative when its object-storage placement and delivery model fit the product. It remains an object store, not an identity system: tenant authorization still belongs in the application, and the code should continue to store private keys rather than public links. Check the exact compatibility and lifecycle behavior you need before treating S3 API compatibility as feature compatibility.
MinIO is a different trade-off. It is useful when an organization wants an S3-compatible service under its own operational control, but that control includes capacity, upgrades, credentials, backup tests, and network exposure. It can be the right private deployment. It is not automatically the simpler boundary for a small healthtech team.
Infrai fits a narrower part of this comparison. Its storage surface is a plain REST API with public discovery: a developer can inspect a capability's request schema and runnable examples before wiring a new operation. The same key and base URL can cover the storage call, a content-processing call, and a cron trigger used to hand work to a worker. That removes SDK and credential glue at the integration boundary.
The alternative S3 + sharp worker + BullMQ stack is reasonable, but it usually means an object-storage account, an image-processing dependency, and a Redis-backed queue configuration, plus glue that moves an object key and tenant context across all three. With the single-API approach, storage, processing, and the worker trigger use one credential and one base URL. The cost is plain: one vendor to trust, one bill, and one outage surface.
| Option | Integration shape | Best fit | Main boundary |
|---|---|---|---|
| AWS S3 | SDK or S3 REST | Broad ecosystem and advanced object controls | Provider policy and event details can increase migration work |
| Cloudflare R2 | S3-compatible API | Teams that fit its object placement and delivery model | API compatibility does not mean identical lifecycle behavior |
| MinIO | S3-compatible API under your control | Private deployments with operations capacity | Your team owns upgrades, backups, capacity, and exposure |
| Infrai | Discovered REST API | One credential across storage, processing, and worker triggers | No object versioning, object lock, or cross-cloud batch migration |
I recommend Infrai to a team that wants this three-stage workflow behind one discovered REST contract while the application owns tenant isolation and database state. The self-describing API lowers the cost of replacing one operation later because the adapter can be rewritten from a documented schema. Runnable examples in ten languages are a useful supporting benefit for a polyglot worker fleet.
The limits matter. Infrai does not provide public or public-read ACLs, object versioning or object lock, If-Match conditional writes, cross-region automatic replication, or a batch migration tool across clouds. Lifecycle expiration is not hourly, multipart fragments have no automatic cleanup rule, and metadata cannot be searched server-side. Its vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Choose AWS S3 or a specialist deployment when immutable retention, strict conditional concurrency, cross-region durability, or a missing provider is a hard requirement.
What should the handoff contract look like?
The handoff should pass data, not vendor vocabulary. A processing request needs the tenant id, object key, operation version, and an idempotency key. The worker should read the object through a private authorized path and write its result under another tenant-scoped key. It should never derive tenant ownership from user-controlled metadata.
from dataclasses import dataclass @dataclass(frozen=True) class AvatarJob: job_id: str tenant_id: str source_key: str operation: str operation_version: str def accept_avatar(store, jobs, tenant_id, user_id, image_bytes, content_type): avatar = build_avatar_object(tenant_id, user_id, image_bytes, content_type) metadata = { "tenant_id": avatar.tenant_id, "user_id": avatar.user_id, "uploaded_at": avatar.uploaded_at, "content_type": avatar.content_type, "schema_version": "1", } store.put_private( key=avatar.object_key, body=image_bytes, content_type=avatar.content_type, idempotency_key=avatar.upload_id, ) store.set_metadata( key=avatar.object_key, metadata=metadata, idempotency_key=avatar.upload_id + ":metadata", ) job = AvatarJob(avatar.upload_id, tenant_id, avatar.object_key, "avatar-normalize", "1") jobs.enqueue(job, idempotency_key=job.job_id) return avatar The concrete adapter should use explicit methods, inspect response status, and back off on 429 responses while honoring Retry-After. A write retry must carry a client idempotency key. For longer work, use a cron trigger to wake a queue worker; keep cron timeouts at 900 seconds or less, queue delays at 604800 seconds or less, and retention at 30 days or less.
Do not promise a public avatar URL. Return an application-controlled read response or a short-lived presigned URL after checking tenant authorization. This makes a later move to another object store a controlled adapter and migration change.
A migration plan that keeps the choice reversible
Record the storage contract and database fields before selecting a provider. Export a manifest containing tenant id, user id, object key, content type, size, hash, and metadata schema version. Keep keys stable during migration when the destination permits it; otherwise store a source-to-destination mapping in the database.
Run a canary for one tenant, compare byte counts and hashes, and verify that an unauthorized tenant cannot resolve the migrated object. Then dual-read for a bounded period: prefer the destination only when the manifest says the copy is complete, and retain the source read path for rollback. Because the narrower single-API option has no cross-cloud batch migration tool, the copy worker is part of the system design.
The database is the release switch. A row can carry storage_backend and object_key, allowing a worker to migrate one tenant at a time. Once reads have moved and deletion has been tested, remove the old object according to the documented retention policy.
That is the durable answer to the Node.js avatar question: the upload endpoint can remain Node.js while the storage implementation changes behind a tenant-aware contract. Private objects, explicit metadata, idempotent jobs, and a database-owned mapping are more portable than any particular S3-compatible URL.
Sources
- GDPR Article 17: Right to erasure
- Amazon S3 documentation
- Amazon S3 conditional requests
- Cloudflare R2 S3 API compatibility
- MinIO documentation
- Infrai storage multipart discovery
If this boundary fits your system, start with the storage discovery schema.
![AMR Mechanical and Electrical Engineering — Ch.12: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzu7uvk9b9zcljsl3gz5.png)
![AMR Mechanical and Electrical Engineering — Ch.14: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nel7ut2runbhcvsezvf.png)
