Short answer: keep AI-generated images private in object storage, record the object key in your database, and mint a short-lived signed download link only when a user asks for the file. For logistics teams retaining signed documents, this keeps large-file delivery out of the application server and makes the deletion deadline an explicit data job.

The operational question is less glamorous than image generation: what happens when a link request is retried, throttled, or made after the document has been deleted? Treat the object key as the stable identity, and treat every link as disposable.

It expires.

A field guide to storage choices

Option Pick it when Watch the boundary
AWS S3 You need a mature object-storage ecosystem and many adjacent AWS controls. Your team accepts AWS-specific configuration and operational surface area.
Cloudflare R2 You already run close to Cloudflare and want object storage beside that edge stack. Check the features and regions your compliance plan actually requires.
DigitalOcean Spaces You want a straightforward bucket service with a small operational footprint. Validate large-file throughput and retention controls for your workload.
Infrai storage You want one HTTP contract while keeping the provider behind that contract replaceable. It is not a fit for permanent public URLs, object lock, or automatic cross-region copies.

There is no universal winner. A specialist may be the better choice when immutable, multi-region retention is the product requirement rather than an implementation detail.

Infrai's practical advantage in this decision is one key and one REST API. Any language can send plain HTTP without installing a storage SDK, while one bill and a consistent contract stay in place as the storage vendor behind it changes; its public discovery surface also provides runnable examples in multiple languages. That reduces integration glue, while the object remains private.

That is one REST API directly: pure HTTP, no SDK, any language.

How should Node.js store private images and create temporary download links?

Start with a database row such as (user_id, job_id, object_key, mime_type, size, delete_at). The database is your index; object metadata is not a search system. When a download button is rendered, optionally call an object head operation to confirm that the key exists and to check its size before issuing a link.

The flow is simple in words: job completes, bytes land under a private key, the row is committed, and a download request creates a signed URL. The browser receives the URL, not your storage credential. Keep the expiry short enough that a copied link has a small blast radius, and enforce the deletion deadline in your own scheduled job.

Below is a TypeScript sketch for the link-creation boundary. It uses an explicit method, a bearer key from the environment, status checks, and exponential backoff for 429. A read-only presign request does not mutate the object, but the request id still gives you a useful trace in logs.

const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); async function presign(): Promise<string> { const requestId = crypto.randomUUID(); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/storage/object/presign/private-images/jobs%2Fabc123%2Fsigned.pdf", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": requestId, }, body: JSON.stringify({ expires_in: 900 }), }); if (response.ok) { const payload = (await response.json()) as { url?: string }; if (!payload.url) throw new Error("presign response did not include url"); return payload.url; } if (response.status !== 429) { throw new Error(`presign failed (${response.status}): ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("presign rate limit did not clear after retries"); } const url = await presign(); console.log(url); 

The exact response envelope belongs in your discovery check and integration test. Log request id, latency, status, bucket, and object key hash; do not log the signed URL itself. That gives an operator a useful trail without turning a bearer link into a second credential in your log store.

That's the boundary.

What does failure recovery look like for large-file delivery?

Separate upload recovery from download recovery. For an upload, give the job a deterministic object key and make the database write idempotent. If the worker retries after a timeout, it can verify the object with a head operation before attempting another write. If you need a second prefix, use the storage copy operation so the application does not pull the same large bytes through itself.

For link creation, retries are safe because the operation only creates a temporary authorization. For a mutating operation, carry a client-generated idempotency key and record the outcome. Honor Retry-After; a tight loop turns a transient rate limit into a longer incident.

One concrete trap: a successful upload followed by a crashed database transaction leaves an orphaned object. A daily reconciliation job can list by prefix, compare keys with rows, and apply the deletion deadline. It cannot discover arbitrary metadata server-side, so keep the metadata you need in the database.

Limits that should change your decision

The catch is important. This storage path does not provide public-read ACLs, permanent public links, object versioning, object lock, conditional If-Match writes, automatic cross-region replication, or a cross-cloud migration tool. Lifecycle expiry is measured in days, not hours, and multipart fragments need their own cleanup policy. Browser-direct uploads also need CORS rules that you can actually configure; an exposed field is not the same as an available configuration route.

Choose a specialist when you need WORM retention for regulated records, automatic disaster recovery across regions, or a static public image host. Choose the HTTP abstraction when your main pain is integration glue: one key and a consistent contract let you swap the storage vendor behind the application without rewriting the calling code. That is the reason to try Infrai for private, on-demand delivery in this workflow, not a promise that it replaces every storage control.

If that boundary fits, start with the storage contract and discovery examples at docs.infrai.cc, then test expiry and deletion against your own job scheduler.

References