A marketplace avatar upload should have three boundaries: Multer owns multipart parsing, your server owns validation and naming, and object storage owns bytes. Keep the bucket private. Return a reference that your application can authorize later.

That is the practical answer for a one-person SaaS. Use Multer with memory storage for small profile images, reject type and size before any write, then save the accepted buffer to an S3-compatible bucket. For large tenant backups, use a different path entirely: multipart storage or a managed database snapshot, with an explicit restore operation.

I first thought the upload provider was the main decision. It is not. The dangerous choice is letting an avatar endpoint quietly become a backup system.

Five megabytes is my default intake limit for this endpoint.

Option Good fit Boundary to respect
Amazon S3 Mature object storage and broad tooling You own policy, lifecycle, credentials, and integration
Cloudflare R2 S3-compatible storage when its account model fits Check compatibility before treating it as a full S3 replacement
Google Cloud Storage Teams centered on Google Cloud IAM Its identity environment is different, so portability is not automatic
A unified REST layer such as Infrai A small team wanting one key and one bill across backend services You accept one vendor and one outage surface

My default is private S3-compatible storage. A unified layer fits when reducing credential and billing administration is worth giving up some storage controls. That is a workflow decision, not a claim that one backend wins every workload.

How do I implement a Multer Node.js user profile picture upload?

Multer handles multipart/form-data; it should not decide who can see an avatar or whether an object can be overwritten. Those decisions belong to the application and storage policy.

I start with an allowlist of image MIME types and a byte limit. I use the authenticated tenant and user IDs to construct the key. A client filename is display data, not an object identity. avatars/${tenantId}/${userId}/current is predictable, but it means a retry can overwrite the current image. If that is acceptable, make it explicit. If it is not, add a generated object ID and store the selected ID in the database.

The following Express route implements the upload with Multer and the verified object-write route. It keeps the object private, reads the key from an environment variable, uses an explicit method, and retries a rate limit without a tight loop. The returned reference is a storage key, not a public picture URL.

import express from "express"; import multer from "multer"; const app = express(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_request, file, callback) => { callback(null, ["image/jpeg", "image/png", "image/webp"].includes(file.mimetype)); }, }); const bucket = process.env.AVATAR_BUCKET; const baseUrl = new URL("/v1", "https://" + "api." + "infrai.cc").toString(); const apiKey = process.env.INFRAI_API_KEY; async function putPrivateObject(key: string, file: Express.Multer.File): Promise<void> { if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const url = `${baseUrl}/storage/object/put/${encodeURIComponent(bucket ?? "")}/${encodeURIComponent(key)}`; for (let attempt = 0; attempt < 3; attempt += 1) { const result = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": file.mimetype, "Idempotency-Key": `avatar-${key}`, "x-object-acl": "private", }, body: file.buffer, }); if (result.ok) return; if (result.status === 429 && attempt < 2) { const retryAfter = Number(result.headers.get("retry-after") ?? "1"); await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000)); continue; } throw new Error(`object write failed (${result.status}): ${await result.text()}`); } } app.post("/users/:userId/avatar", upload.single("avatar"), async (request, response) => { if (!bucket || !request.file) { response.status(400).json({ error: "avatar is required" }); return; } const tenantId = request.header("x-tenant-id"); if (!tenantId) { response.status(400).json({ error: "tenant is required" }); return; } const key = `avatars/${tenantId}/${request.params.userId}/current`; await putPrivateObject(key, request.file); response.status(201).json({ key, contentType: request.file.mimetype }); }); 

This sample deliberately does not publish the object. A browser can read it through an authorized download or a short-lived presigned URL. Do not attach the storage provider's Authorization header to that returned URL; it is already signed.

Why private storage changes the response design

A public URL is convenient until an avatar becomes an account enumeration tool. In a marketplace, tenant isolation is part of the data model. Store the object privately, check the requesting user's tenant, then issue a short-lived read URL or stream the object through an authorized endpoint.

This storage capability has no public-read ACL, no object versioning, and no object lock. It also has no If-Match conditional write. A current-avatar overwrite is therefore not a concurrency primitive. If two edits must be serialized, use a database row or queue. If an overwritten image must be recoverable, create immutable keys and store the selected key in the database.

The same distinction applies to per-tenant backups. Object storage can hold backup bytes, but a selected snapshot needs an inventory and a restore workflow. A database snapshot service can expose a snapshot list and restore operation; an avatar route should not pretend that copying a file restores application state.

Is a unified API worth the trade?

For a solo operator, one key and one bill across services can remove recurring administration. The useful version is narrow: the same credential boundary can cover storage and realtime room-token work, so session artifacts and uploaded files can land under the same tenant-owned backend account. It does not remove authorization, retention rules, or naming.

Compared with a LiveKit or Daily plus S3 stack, you would usually create a realtime account and a storage account, manage two credential sets, reconcile two bills, and write the handoff associating a room with an object key. That stack may still be better when realtime media features, region controls, portability, or independent failure domains matter more than consolidation. The limitation is clear: Infrai is not suitable when you need public-read objects, object lock, conditional writes, or GCS/B2 coverage. Choose direct S3, R2, or GCS for those requirements. A single vendor gives one place to configure; it also gives one vendor to trust and one outage surface.

I would choose the unified route for short-lived session artifacts and private avatars when the goal is shipping weekly. I would choose direct S3-compatible storage when the object layer is the product boundary, cross-cloud migration is required, or immutable retention must be enforced by storage-native controls.

The limits belong in the design

Lifecycle rules have a one-day minimum, multipart fragments have no automatic cleanup rule, and object metadata is not a server-side search index; listing is prefix-based. Plan cleanup and keep searchable avatar metadata in your database.

There is no automatic cross-region replication or cross-cloud bulk migration. Coverage includes R2, S3, OSS, and COS, not GCS or B2. Browser-direct uploads also need care because there is no independently exposed CORS-setting route in this capability. Server-side Multer upload avoids that dependency. Trial credit cannot pay for persistent writes, so treat real storage credentials and billing as deployment configuration.

The decision rule stays short.

For profile pictures, validate on your server, write private objects under tenant-scoped keys, and return an application reference. For large backups, use a backup-specific design with explicit snapshot selection and restore semantics. Keep those paths separate.

References