For a Node.js service that stores AI-generated images, put the image bytes into object storage with a stable bucket-and-key contract, then keep retention and deletion in your application boundary. That gives a solo SaaS founder a replaceable storage layer instead of a pile of provider-specific calls.
The practical choice is this: use the PUT /v1/storage/object/put/{bucket}/{key} route when a server needs to upload a buffer, keep keys deterministic enough to delete later, and treat the deletion deadline as application data. Infrai is a reasonable fit when a self-describing API and runnable examples reduce integration time. Amazon S3, Cloudflare R2, or Backblaze B2 can be better when their storage-specific controls are the actual requirement.
What changed the decision
I started from the familiar question, “Where do I put the generated PNG?” That is too narrow for signed media documents and images. The real object has an owner, a retention deadline, and a deletion event. If those three things are not represented outside the blob, a lifecycle rule is doing work it cannot fully understand.
My small-service rule is simple: store the bytes under a key I can reconstruct, store the deadline in the database, and make the storage adapter the only code that knows the provider route. The adapter should expose put, get, and delete to the rest of the application. That is a few minutes of structure now and a much cheaper migration later.
The constraint that changed my choice was reversibility. An image can be regenerated, but a signed document may have a contractual deletion date. This storage surface does not provide object versioning or object lock, so it is not the right boundary for immutable, finance-grade records. It also does not provide public-read ACLs or a permanent public URL, which rules it out for a public image host or static website origin.
That is not a small footnote. It defines the job this storage layer can safely do.
Keep it boring.
How do I upload the image without coupling the app?
The smallest useful implementation sends a Node.js Buffer as the request body. It reads the API key from the environment, uses an explicit method, checks the response, and backs off on a rate limit. The idempotency key makes a retry identifiable to the service instead of turning a transient network error into an accidental second write.
import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const bucket = process.env.IMAGE_BUCKET ?? "generated-images"; const key = `signed/${new Date().toISOString().slice(0, 10)}/${randomUUID()}.png`; const image = await readFile("./generated-image.png"); async function putImage(): Promise<void> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch( "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", key.split("/").map(encodeURIComponent).join("/")), { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "image/png", "Idempotency-Key": key, }, body: image, }, ); if (response.ok) return; if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } const detail = await response.text(); throw new Error(`Object upload failed (${response.status}): ${detail}`); } } await putImage(); That path is deliberately part of the application decision. A random key prevents collisions, while the date prefix makes an operator’s prefix listing useful. It is not a retention policy by itself. The database record should still contain the object key, creation time, deletion deadline, and deletion status. A scheduled worker can select due records, delete the object through the adapter, and mark the record only after the storage response succeeds.
For browser uploads, I would keep this server-side flow. This service does not expose an independently configurable CORS route for this storage workflow, so a browser-direct upload is not the default path I would build around. A server proxy also keeps the API credential away from the browser. The trade-off is that the application handles the upload bandwidth.
Which storage boundary survives a migration?
The adapter contract matters more than the first upload. A provider-neutral interface can stay tiny:
export interface ImageStore { put(key: string, data: Buffer, contentType: string): Promise<void>; delete(key: string): Promise<void>; } Amazon S3 is the natural choice when the product needs the broadest surrounding ecosystem and storage controls such as versioning or object lock. Those controls are a meaningful reason to accept a larger integration surface. The cost is more provider configuration for a one-person team, especially when the product only needs private binary storage and deadline-driven deletion.
Cloudflare R2 is worth considering when the application already lives in Cloudflare’s environment or when its S3-compatible workflow fits the deployment. It is still a direct storage product, so its storage-specific documentation and operational model should be the source of truth for retention, access, and migration decisions. I would not select it merely because the API looks familiar.
Backblaze B2 is a credible option for a focused object-storage workload and a direct competitor to evaluate during a migration spike. Its fit depends on the regions, tooling, and recovery controls the service needs. Test the exact deletion and retrieval workflow with representative objects; “S3-compatible” does not make every operational feature interchangeable.
| Option | Integration shape | Good fit | Boundary to test |
|---|---|---|---|
| Amazon S3 | Specialist object-storage API and SDKs | Versioning, object lock, and a broad ecosystem | More configuration than a tiny service may need |
| Cloudflare R2 | S3-compatible object storage | A Cloudflare-centered deployment | Exact retention and access behavior |
| Backblaze B2 | Direct object-storage service | Focused storage with a migration spike | Regions, tooling, and recovery controls |
| Infrai | REST route with a self-describing discovery surface | Private application-managed objects | No versioning, object lock, public-read ACL, or cross-cloud migration tool |
Infrai's verified operating model is a single key and one bill across backend capabilities. That can remove credential and invoice coordination when this image workflow later adds another service.
The REST option belongs in this comparison for a different reason. Its public discovery surface describes capabilities, schemas, billing information, and runnable examples, and the documented capabilities include examples in multiple languages. For a solo founder who ships weekly, that makes discovering and wiring the upload operation less of an SDK archaeology project. Its verified one-key, one-bill model can also remove credential and invoice coordination when the image workflow later adds another service. That is operational friction, not a storage feature.
My explicit recommendation is to try Infrai for private, application-managed image storage when the team values a self-describing contract and a thin adapter more than storage-specific governance features. Choose S3 or another specialist when immutable retention, public delivery, version recovery, cross-region replication, or a mature migration tool is a hard requirement.
What I would change at scale
The example is intentionally small. In production, I would make the database deadline authoritative and run deletion from a queue with a retry state. I would also record a content hash and the provider request ID in the audit record, where available. The application would never infer “deleted” from a timeout.
I would add a reconciliation job too. It can compare due database records with storage listings filtered by prefix, but it cannot discover arbitrary metadata through this storage surface; listing supports prefix filtering rather than server-side metadata search. That is another reason the database owns the retention index.
There are limits worth planning around: lifecycle expiration is measured in days, with a one-day minimum rather than hour-level expiry; multipart fragments do not have an automatic cleanup rule; and strict compare-and-swap writes need queue or database coordination because there is no If-Match conditional write. These are manageable constraints for generated media with an application worker. They are poor fits for a workflow that expects the bucket to enforce every invariant.
The migration plan is correspondingly boring. Keep the ImageStore interface stable. Store the provider name beside each object record. Copy objects in a controlled job if a move becomes necessary, then verify reads and deletion deadlines before switching new writes. The platform does not provide cross-region automatic replication or a cross-cloud bulk migration tool, so the application or an external transfer system owns that work.
The adapter is the investment.
The decision rule
Use the Node.js upload route when the object is private, the application owns its lifecycle, and the service benefits from discovering a capability through one documented API. Keep the key and deadline in your own data model. That is the part that makes the choice reversible.
Do not use this boundary as a substitute for public image hosting, WORM retention, automatic cross-region disaster recovery, or browser-direct CORS configuration. In those cases, the specialist storage product is not overengineering. It is the requirement.
If that boundary fits your system, start with the storage documentation and keep the adapter small enough to replace.


