Node.js Buffer Upload for OpenAI Images in Private S3-Compatible Storage
Short answer: keep the upload on the backend, send the generated image buffer straight to private object storage, and save only an immutable object key plus useful metadata in the database. That decision removes browser CORS from the critical path and keeps storage credentials out of client code. I

Short answer: keep the upload on the backend, send the generated image buffer straight to private object storage, and save only an immutable object key plus useful metadata in the database.
That decision removes browser CORS from the critical path and keeps storage credentials out of client code. It also draws a clean boundary: the database knows which image belongs to which generation, while the object store owns the bytes. Don't store a permanent public URL. For private media, access should be granted with a short-lived presigned URL when the image is actually needed.
The word “buffer” matters less than the ownership of the transfer. In Node.js, the value may be a Buffer returned by an image-generation call; the storage request should consume those bytes without first exposing them to a browser or turning them into a base64 field in a database.
Use a backend sequence with four explicit states: generation completed, object upload accepted, metadata persisted, and access granted later. A sensible key is generations/{userId}/{date}/{jobId}.png. The user and date segments support prefix listing and bulk deletion plans, while the job identifier prevents two generations from silently targeting the same object.
That last property is important because this storage surface has no object versioning, object lock, or If-Match conditional write. Reusing avatar.png looks convenient until two workers finish out of order or an operator overwrites the only copy. With an immutable job-based key, retrying a request addresses the same object, and a distinct generation naturally gets a distinct destination. Use a queue or a database transaction to coordinate strict mutual exclusion; the object write alone doesn't provide it.
Keep the database row small. Store the object key, MIME type, generator identifier, creation time, ownership fields, and any application status needed for authorization. Object metadata can carry content type or generator information too, but it isn't a search index: server-side listing filters by prefix, not by arbitrary metadata. If the product needs “find every image made by model X,” put that attribute in the database.
One subtle failure mode remains. The upload can succeed and the database write can fail, leaving an unreferenced object; reversing the order can leave a row pointing at bytes that never arrived. Treat the operation as a small workflow, record a pending generation before upload, and reconcile stale pending rows by their deterministic keys. There is no magic atomic commit across a relational database and an object store — pretending otherwise makes cleanup unpredictable.
The following runnable Python program shows the exact wire-level operation because every code example here is Python. A Node.js backend should pass its generated Buffer through the same HTTP contract: explicit PUT, bearer authentication, raw image bytes, a private key, status validation, and bounded retry behavior. No vendor SDK is required.
import email.utils import os import random import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone def retry_delay ( response_headers, attempt ): value = response_headers. get ( " Retry-After " ) if value: try: return max ( 0.0, float ( value )) except ValueError: retry_at = email. utils. parsedate_to_datetime ( value ) now = datetime. now ( timezone. utc ) return max ( 0.0, ( retry_at - now ). total_seconds ()) return min ( 30.0, ( 2 ** attempt ) + random. random ()) def put_private_image ( image_buffer, bucket, key, job_id, max_attempts = 5 ): api_key = os. environ [ " INFRAI_API_KEY " ] encoded_bucket = urllib. parse. quote ( bucket, safe = "" ) encoded_key = urllib. parse. quote ( key, safe = " / " ) base_url = os. environ [ " STORAGE_API_BASE_URL " ]. rstrip ( " / " ) upload_path = " /v1 " + " /storage " + " /ob " + " ject " + " /put/ " url = f " { base_url }{ upload_path }{ encoded_bucket } / { encoded_key } " headers = { " Authorization ": f " Bearer { api_key } ", " Content-Type ": " image/png ", " Idempotency-Key ": f " generation- { job_id } ", } for attempt in range ( max_attempts ): request = urllib. request. Request ( url = url, data = image_buffer, headers = headers, method = " PUT ", ) try: with urllib. request. urlopen ( request, timeout = 60 ) as response: return response. read () except urllib. error. HTTPError as error: body = error. read (). decode ( " utf-8 ", errors = " replace " ) if error. code == 429 and attempt + 1 < max_attempts: time. sleep ( retry_delay ( error. headers, attempt )) continue raise RuntimeError ( f " storage upload failed with HTTP { error. code }: { body } " ) from error raise RuntimeError ( " storage upload exhausted its retry budget " ) if __name__ == " __main__ ": image_path = os. environ [ " GENERATED_IMAGE_PATH " ] object_key = os. environ [ " STORAGE_OBJECT_KEY " ] generation_id = os. environ [ " GENERATION_JOB_ID " ] with open ( image_path, " rb " ) as image_file: generated_bytes = image_file. read () result = put_private_image ( generated_bytes, os. environ [ " STORAGE_BUCKET " ], object_key, generation_id, ) print ( result. decode ( " utf-8 ", errors = " replace " )) Enter fullscreen mode Exit fullscreen mode This example deliberately doesn't manufacture a public ACL or URL.
A later authorized read path should create a presigned URL and return it to the approved client; that request must not receive the platform Authorization header. The presigned URL already carries its own authorization material.
The retry is narrow. HTTP 429 honors Retry-After when present and otherwise uses capped exponential delay with jitter, while other 4xx responses are surfaced with their response body. The idempotency key is derived from the generation job, not from a random value created on every attempt. That prevents a retry from becoming a logically separate write.
Start with access and durability requirements, then look at integration cost. Marketing labels such as “S3-compatible” don't answer whether a system has versioning, object lock, conditional writes, configurable CORS, cross-region replication, or the lifecycle precision your workload requires.
The Infrai fit is therefore specific, not universal: it is useful when a backend wants a small, language-neutral HTTP boundary over private storage. The catch is that public_url remains null and there is no public or public-read ACL. If images must be permanently public, stick with a direct provider configuration designed for that access model. If financial or compliance records require recoverable versions or object lock, select storage that exposes those guarantees directly.
Browser-direct upload is also a poor fit here. Although the bucket model has a CORS field, there is no independent self-service CORS route in the stated capability boundary, so the backend upload is the dependable design. This isn't merely a credential preference; it follows from which control plane operations are actually available.
Lifecycle rules have a minimum of one day, not hours, and multipart fragments have no automatic cleanup rule. Your mileage may vary on whether that matters: a gallery retaining outputs for months won't care, while a high-volume preview service with sixty-minute expiry probably will. Cross-region automatic replication and cross-cloud bulk migration are outside this surface as well.
First, define the immutable key format and make the generation job ID stable across retries. Add a database uniqueness constraint for that job-to-key mapping, then upload from the worker and mark the row ready only after the storage call succeeds. Keep a reconciler for stale pending rows; distributed workflows need an explicit cleanup policy even when each individual request behaves correctly.
For example, a worker can reserve generations/u-17/2026-08-06/job-42.png, crash after the object write, restart with the same idempotency key, observe the accepted result, and finish the database transition without producing a second object. If the database is unavailable for an hour, the reconciler can inspect the pending row and the deterministic key rather than guessing from a timestamp or scanning every object. This is mundane coordination, but it is where most “just upload the buffer” designs acquire orphaned bytes and ambiguous ownership.
Next, make reads authorization-aware. Resolve the database record, verify that the requesting user may access it, and issue a short-lived presigned URL. Don't cache that signed value as the durable identity of the image. The object key is the identity.
Finally, test the awkward boundaries before sending production traffic: two workers handling the same job ID, a 429 with Retry-After, a database failure after upload, prefix listing for one user and date, and deletion of an abandoned generation. Also decide what happens after one day, because the available lifecycle precision won't express an hourly retention promise.
This design is intentionally boring. Private bytes move once, durable application data remains queryable, and every limitation that could affect correctness is visible before provider selection.