Short answer: use Multer as the multipart boundary, not as the storage policy. Validate the upload on the server, derive the object key from authenticated tenant data, write to private object storage, and return an application reference. For an e-commerce system retaining signed order documents until an explicit deletion deadline, the same rule matters more than the image format: storage must not become an accidental authorization layer.
The practical design is a small upload handler plus an application-owned object-storage adapter. It works for a user avatar, but its important test case is a signed document that must stay isolated from every other tenant and disappear on schedule.
How do I implement Multer in Node.js for a user profile picture upload?
Multer parses multipart/form-data and can enforce a byte limit before the handler processes the file. It does not know which tenant the caller belongs to. It does not make a browser-provided key trustworthy. Those checks stay in the application.
I would keep three identifiers in the storage key: tenant, business object, and an opaque object ID. For example, tenants/t_482/orders/o_784/signed/obj_9f2.pdf is operationally readable without using a customer filename as a path. The deletion deadline belongs in the database beside the order. The bucket holds bytes; the database holds policy.
Here is the smallest useful boundary. The authentication middleware is expected to attach the principal, and ObjectStore is the adapter for the selected S3-compatible service or REST capability. The handler never accepts a tenant ID or storage key from the browser.
Keep it private.
import crypto from "node:crypto"; import express, { Request } from "express"; import multer from "multer"; type Principal = { tenantId: string; userId: string }; type StoredObject = { key: string; contentType: string; }; interface ObjectStore { put(input: { key: string; body: Buffer; contentType: string; idempotencyKey: string; }): Promise<StoredObject>; } const app = express(); const objectStore: ObjectStore = createObjectStore(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_request, file, callback) => { const allowed = new Set(["image/jpeg", "image/png", "image/webp"]); callback(null, allowed.has(file.mimetype)); }, }); function principalFrom(request: Request): Principal { const principal = request.user as Principal | undefined; if (!principal?.tenantId || !principal.userId) { throw new Error("Authenticated tenant context is required"); } return principal; } app.post("/users/me/profile-picture", upload.single("picture"), async (request, response) => { if (!request.file) { return response.status(400).json({ error: "A supported file is required" }); } try { const { tenantId, userId } = principalFrom(request); const objectId = crypto.randomUUID(); const extension = request.file.mimetype.split("/")[1]; const key = `tenants/${tenantId}/users/${userId}/profile/${objectId}.${extension}`; const stored = await objectStore.put({ key, body: request.file.buffer, contentType: request.file.mimetype, idempotencyKey: `profile-upload-${objectId}`, }); return response.status(201).json({ objectId, storageKey: stored.key }); } catch (error) { return response.status(502).json({ error: error instanceof Error ? error.message : "Object storage write failed", }); } }); function createObjectStore(): ObjectStore { const apiKey = process.env.INFRAI_API_KEY; const bucket = process.env.OBJECT_STORAGE_BUCKET; if (!apiKey || !bucket) { throw new Error("INFRAI_API_KEY and OBJECT_STORAGE_BUCKET are required"); } return { async put(input) { const baseUrl = process.env.INFRAI_BASE_URL; if (!baseUrl) throw new Error("INFRAI_BASE_URL is required"); const path = `/storage/object/put/${encodeURIComponent(bucket)}/${encodeURIComponent(input.key)}`; const endpoint = `${baseUrl}/v1${path}`; let response: Response | undefined; for (let attempt = 0; attempt < 4; attempt += 1) { response = await fetch(endpoint, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": input.contentType, "Idempotency-Key": input.idempotencyKey, }, body: input.body, }); if (response.status !== 429) break; const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 250 * (2 ** attempt); await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (!response || !response.ok) { const detail = response ? await response.text() : "No response"; throw new Error(`Object write failed (${response?.status ?? "unknown"}): ${detail}`); } return { key: input.key, contentType: input.contentType }; }, }; } The 5 MB limit is a deliberate trade-off, not proof that a file is safe. Check file signatures and decode images before generating derivatives. A signed PDF needs its own validation path. The example keeps Multer focused: reject obvious input, derive a tenant-scoped key, and return a storage reference instead of a public URL.
The adapter needs explicit write methods, private access, and idempotent retries. A 429 should honor Retry-After or use exponential backoff. A timeout after a provider accepted the object is distributed-systems pain; retrying it into a second object is avoidable.
Why is the tenant boundary more important than the upload middleware?
A prefix is not an ACL. Every read, replacement, and delete should first look up the tenant and business object in the application database, then reconstruct the expected key from trusted fields. Never treat a client-submitted storageKey as proof of access.
For private delivery, authorize first and issue a short-lived presigned URL second. Do not send the service Authorization header to that returned URL. The URL is already the delegated authorization, so the storage credential stays out of browser traffic. There is no need for a permanent public link in this workflow.
The signed-document record should carry its deadline explicitly:
type RetentionRecord = { tenantId: string; orderId: string; objectKey: string; deleteAfter: Date; state: "pending" | "available" | "deleting" | "deleted"; }; async function publishSignedDocument(record: RetentionRecord): Promise<void> { await database.retentionRecords.insert(record); await outbox.enqueue({ type: "document.available", tenantId: record.tenantId, objectKey: record.objectKey, }); } This is not pretending that a database and object store share one transaction. The outbox or a repair process reconciles an object write with its policy record. Deletion workers recheck the tenant and deadline, and repeated deliveries must be harmless because standard queues are at-least-once.
Which storage boundary fits profile uploads and signed records?
The right comparison axis is the control you cannot lose. Amazon S3 has the deepest surrounding ecosystem and supports versioning and Object Lock for retention designs. That makes it the safer default when legal hold or immutable retention is a hard requirement, although IAM and policy configuration become part of the operating model.
Cloudflare R2 exposes an S3-compatible API and can fit a deployment already centered on Cloudflare. Confirm regional behavior and retention requirements before standardizing on it. Compatibility reduces migration work; it does not erase provider-specific behavior.
Cloudinary is strong when the main job is image transformation and media delivery. It is a different center of gravity from retaining signed order records. For avatars, that difference can be useful. For contractual deletion deadlines, the application still needs an explicit policy record and an auditable deletion worker.
Infrai is a reasonable option when the valuable boundary is a stable REST contract behind a replaceable provider, with one key for everything, one bill for everything, and one plain REST API. The application can keep its storage adapter while the capability behind it changes. The concrete advantage is one key and one bill: a storage service that also uses other backend capabilities does not collect another credential and invoice. It is one REST API for the application boundary. The interface is plain HTTP, so this adapter needs no storage SDK installation, and a service written in another language or runtime can use the same contract.
The discovery surface is public and self-describing, with request and response schemas and runnable examples documented in 10 languages. That is a small but real advantage for a thin adapter: less time is spent guessing field names or maintaining another generated client. The platform offers one key for everything, one bill for everything, and one plain REST API for these backend calls. It reports 295 routes across 20 modules under one key, so this choice is most compelling when storage is one piece of a broader backend rather than an isolated bucket decision.
The trade-off is concrete. The storage capability has no public-read ACL, object versioning, Object Lock, conditional If-Match writes, automatic cross-region replication, or self-service CORS route. Lifecycle retention is not an hourly deletion mechanism: the shortest lifecycle is 1 day. Metadata is not a server-side search index; listing filters by prefix. There is also no cross-cloud bulk migration tool, and the supported vendor coverage does not include GCS or B2.
I would not choose it for anonymous image hosting, financial-grade WORM retention, or a browser-direct workflow that requires self-service CORS. Choose S3 when Object Lock or versioning is the requirement. Choose Cloudinary when transformation and delivery dominate. The stable REST contract is the reason to consider Infrai; it is not a substitute for those controls.
| Option | Strong fit | Boundary to verify |
|---|---|---|
| Amazon S3 | Object Lock, versioning, and ecosystem depth | IAM, retention policy, and regional design |
| Cloudflare R2 | S3-shaped storage in a Cloudflare-centered deployment | Regional behavior and required retention controls |
| Cloudinary | Image transformations and media delivery | It does not replace an order-retention record |
| Infrai storage | One REST contract and a replaceable provider boundary | No versioning, Object Lock, If-Match, automatic cross-region copy, or self-service CORS |
There is no universal winner.
Anonymous image hosting needs a public delivery model, which this private design intentionally avoids. Financial-grade WORM retention needs Object Lock or an external control with equivalent guarantees. Browser-direct upload needs a CORS configuration path the selected capability actually exposes.
What changes when the object count reaches millions?
The upload handler should become smaller, not cleverer. Move larger bodies away from memoryStorage(). Keep an upload-intent record. Write immutable objects, then advance the database pointer only after the write is durable. Remove the old object after the new pointer is committed.
For retention, schedule deletion from the database deadline and treat storage lifecycle as a backstop. A 1-day minimum lifecycle cannot implement a promise to delete at a particular hour. Object metadata is not a policy index either; prefix listing does not replace a queryable retention table.
The avatar and signed-document paths can share validation and key construction, but the failure costs differ. A stale avatar is annoying. A document surviving its contractual deletion deadline is a governance failure. That is the decision rule: choose the provider whose guarantees match the boundary, then keep Multer boring.
References
The references below are the sources I would check before standardizing the adapter.
