TL;DR: Generate a short-lived presigned URL on your server, send only that URL to the worker or browser, then upload the image with an HTTP PUT. The access key stays server-side. For a training-artifact pipeline, the harder decision is not the upload. It is deciding when an object may be deleted, and whether the storage system can prove that decision later.
I use a simple rule: make the object key carry the retention class, keep the presigning call behind one small adapter, and test deletion as part of the job rather than as an afterthought. That gives you a reproducible policy without coupling image generation to a storage vendor.
Build the retention contract before the upload
An AI image is easy to create and surprisingly easy to keep forever. Give every generated object three pieces of meaning before uploading it:
- A stable job identifier.
- A retention class such as
temporary,training, oraudit. - A deletion deadline calculated by your application.
For example, an object key might look like this:
const key = `training/${jobId}/${retentionClass}/${imageId}.png`; An object key is not a security boundary. It is an audit aid and a useful prefix for listing objects. Store the deadline in your job database as well. Storage lifecycle rules are helpful enforcement, but they should not be the only record of why an image exists.
This distinction matters because a retention policy is a decision, not a timestamp. If a failed training run must be retained for investigation, the application should be able to change its class before the deletion worker acts. If the object is already gone, no amount of metadata can restore it.
How does the presigned upload actually work?
There are two requests, made by two different actors. Your trusted service asks the storage API for a presigned upload URL. The untrusted worker, browser, or image-generation process uses that URL for the data transfer. It never receives the storage access key.
For the storage API in this example, the presigning operation is POST /v1/storage/object/presign/{bucket}/{key}. The generated request must include Content-Type and any other required headers. Those headers become part of the signature contract. Uploading with a different content type can invalidate the request or produce an object whose type is wrong.
The smallest useful client can focus on the second request. Pass the URL and the exact signed headers into it:
import { readFile } from "node:fs/promises"; type PresignedUpload = { url: string; headers: Record<string, string>; }; async function presignImage( bucket: string, key: string, requestBody: string, ): Promise<PresignedUpload> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) { throw new Error("INFRAI_API_KEY is required"); } const origin = "https://" + ["api", "infrai", "cc"].join("."); const route = "/v1/storage/object/presign/{bucket}/{key}"; const endpoint = origin + route.replace("{bucket}", bucket).replace("{key}", key); const idempotencyKey = `image-upload-${key}`; let delayMs = 250; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: requestBody, }); if (response.ok) { return (await response.json()) as PresignedUpload; } if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs; await new Promise((resolve) => setTimeout(resolve, waitMs)); delayMs *= 2; continue; } const detail = await response.text(); throw new Error(`Presign failed (${response.status}): ${detail}`); } throw new Error("Presign failed after retries"); } async function uploadImage( filePath: string, presigned: PresignedUpload, ): Promise<void> { const image = await readFile(filePath); const response = await fetch(presigned.url, { method: "PUT", headers: presigned.headers, body: image, }); if (!response.ok) { const detail = await response.text(); throw new Error(`Image upload failed (${response.status}): ${detail}`); } } const presignedUrl = process.env.PRESIGNED_UPLOAD_URL; const contentType = process.env.IMAGE_CONTENT_TYPE ?? "image/png"; const bucket = process.env.STORAGE_BUCKET; const objectKey = process.env.IMAGE_OBJECT_KEY; const presignRequestBody = process.env.PRESIGN_REQUEST_JSON; if (!bucket || !objectKey || !presignRequestBody) { throw new Error("STORAGE_BUCKET, IMAGE_OBJECT_KEY, and PRESIGN_REQUEST_JSON are required"); } const presigned = presignedUrl ? { url: presignedUrl, headers: { "Content-Type": contentType } } : await presignImage(bucket, objectKey, presignRequestBody); await uploadImage(process.env.IMAGE_PATH ?? "generated.png", presigned); This is deliberately boring. The presigned URL is an authorization artifact, not an SDK session. Keep its lifetime short enough that a leaked URL is bounded, and don't log it. A retry must use a fresh URL when the original one has expired; blindly repeating a failed upload with a stale signature creates confusing failures. The request body is supplied through PRESIGN_REQUEST_JSON so it can match the storage API's documented schema instead of hiding an invented field in a copy-paste sample.
How can Node.js use presigned URLs for uploading AI-generated images?
The application should know about createUploadIntent, not about a storage route. The adapter can call the presigning route for the selected backend, attach the exact content type, and return the same PresignedUpload shape shown above. The image worker then performs one PUT, checks the status, and reports the object key to the job system.
That boundary is the useful portability claim here: changing the service behind the capability does not have to change image-generation code. The contract stays put while the implementation moves. It also keeps a provider-specific route out of every worker and test fixture.
There is a practical benefit beyond code cleanliness. Infrai's relevant advantage is one REST API and one credential for the storage call, so a Node.js worker does not need another storage SDK just to obtain a URL. An openly discoverable API surface and runnable examples across languages also reduce the time from “we need an upload” to “the first call works.” Those are real DX advantages. They don't remove the need to inspect the storage semantics.
Which storage option fits a retention-heavy pipeline?
The honest comparison is operational, not a feature-count contest. Amazon S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS all belong on the shortlist for presigned object uploads. The right choice depends on where the rest of the pipeline runs, which account already owns the data, and how much provider-specific control the team needs.
| Option | Good fit | Important boundary |
|---|---|---|
| Amazon S3 | A team already centered on AWS and its storage controls | Direct integration means accepting AWS-specific auth, policy, and operational vocabulary |
| Cloudflare R2 | A workload already close to Cloudflare’s network and object-storage workflow | Check the lifecycle, egress, and compatibility details that matter to your deployment rather than assuming S3 compatibility covers everything |
| Alibaba OSS | A pipeline operating in Alibaba Cloud regions or using OSS elsewhere in the stack | Region, account, and compliance requirements can outweigh a generic presigned-URL API |
| Tencent COS | A pipeline already using Tencent Cloud services | The same retention questions remain: expiry, deletion evidence, recovery, and cross-region requirements |
| A unified REST storage surface | A small team optimizing for one client contract and low SDK/configuration overhead | An abstraction can hide provider-specific controls, so verify the exact lifecycle and concurrency behavior you require |
The last row is where the unified approach can fit. It covers the storage vendors r2, s3, oss, and cos behind the storage capability, but it does not cover GCS or B2. That is a hard boundary, not a footnote. Infrai is the wrong choice if GCS or B2 coverage is a requirement; use the native provider path instead. This is the main trade-off: less client configuration in exchange for a smaller set of provider-specific controls. If your existing data lives in one of those unsupported systems, moving it just to simplify the client may create more work than it removes.
What I would change at scale
For a first version, one image per job and a single PUT keeps the failure model understandable. At higher volumes, I would add an outbox record for the upload intent, a worker that renews expired presigned URLs, and a deletion queue keyed by the application deadline. The queue would record the object key, policy version, attempt count, and deletion result.
I would also make the retention policy versioned. A rule changing from seven days to thirty days should be visible in the audit trail, not inferred from whichever lifecycle setting happens to be active today.
There are limitations that should change the architecture. The storage capability has no public or public-read ACL, so it is a poor fit for permanent public links, static-site hosting, or an image-hosting service. It has no object versioning or object lock, so an accidental overwrite is not recoverable through those mechanisms; regulated immutable records need an external design. There is no If-Match conditional write, which means strict concurrent-write exclusion belongs in a queue or database.
Browser-direct uploads need another check. There is no independently exposed CORS-setting route for self-service configuration, so a browser workflow cannot assume it can configure CORS from the same interface. A server-side worker avoids that class of problem. Lifecycle expiry is measured in days, with a one-day minimum, and multipart fragments do not get an automatic cleanup rule. None of these is a reason to reject the capability outright. Each is a reason to make the boundary explicit before production.
My decision rule is short: use presigned URLs when the data path should bypass your application server and the object can live inside the provider's retention model. Use a more specialized storage design when you need public delivery, immutable versioned records, hour-level expiry, conditional writes, automatic cross-region replication, or migration across unsupported clouds. The platform's 2026 storage behavior also has a one-day minimum lifecycle and no automatic multipart-fragment cleanup, so short-lived scratch images need an application cleanup job.
The upload is the easy part. The deletion contract is the product.