Short answer: stream the image into an S3-compatible object store with a stable, private key, then issue a short-lived presigned download URL. Keep the object private. Do not treat public-read ACLs as a shortcut for expiring access: that ACL is not supported in the storage scenario here, and a public URL cannot enforce a deadline.

The operational decision is straightforward. Use an S3-compatible service when the application already speaks the S3 API and delivery simplicity matters. Put the access-control policy in the application layer, make each upload key unique, and verify the object before publishing its URL. This is the part that prevents a successful image generation job from turning into a duplicate object or an accidentally public file.

What failure are we trying to prevent?

The failure signal is usually not a dramatic outage. It is a job marked complete while the object is missing, a retry that overwrites a different user's image, or a download link that outlives the authorization that created it. Those failures are quiet until a customer reports them.

For a B2B SaaS upload, I would use a key such as accounts/42/images/job-7f3c.png, where the job identifier is generated once and retained with the database record. The generated bytes can be consumed as a readable stream. The storage request should carry the content type and any metadata the application needs at write time; metadata is not a substitute for an authorization record, since it cannot be used as a server-side search index here.

The retry rule is the important one. Check for an existing key before creating it, and have the database decide whether that key belongs to the same job. An object store without an If-Match conditional write cannot provide strict mutual exclusion by itself. A queue or database transaction must own that decision.

How can Node.js stream AI-generated images into S3-compatible storage?

The question is less about buffering than about ownership of the bytes. A Node.js pipeline can pass the generated image directly to an S3-compatible client, but the same design principle applies to any client: the producer must report errors, the upload must report its final status, and the application must not publish a link before both have completed.

The task requires a Go example, so this small REST example shows the storage boundary without pretending that a public ACL is available. In production, the io.ReadSeeker would be connected to a temporary image file or another replayable stream. A replayable source matters because a rate-limit retry cannot resend bytes that have already been consumed.

package main import ( "context" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func putPrivateImage(ctx context.Context, bucket, key string, image io.ReadSeeker, jobID string) error { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { return errors.New("INFRAI_API_KEY is required") } base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/") + "/storage/object" if base == "/storage/object" { return errors.New("INFRAI_BASE_URL is required") } headURL := fmt.Sprintf("%s/head/%s/%s", base, url.PathEscape(bucket), url.PathEscape(key)) headReq, err := http.NewRequestWithContext(ctx, http.MethodGet, headURL, nil) if err != nil { return fmt.Errorf("build existence check: %w", err) } headReq.Header.Set("Authorization", "Bearer "+apiKey) headResp, err := http.DefaultClient.Do(headReq) if err != nil { return fmt.Errorf("check existing object: %w", err) } headResp.Body.Close() if headResp.StatusCode == http.StatusOK { return fmt.Errorf("refusing to overwrite existing object %q", key) } if headResp.StatusCode != http.StatusNotFound { return fmt.Errorf("existence check returned %s", headResp.Status) } putURL := fmt.Sprintf("%s/put/%s/%s", base, url.PathEscape(bucket), url.PathEscape(key)) for attempt := 0; attempt < 3; attempt++ { if _, err := image.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("rewind image: %w", err) } putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, image) if err != nil { return fmt.Errorf("build upload: %w", err) } putReq.Header.Set("Authorization", "Bearer "+apiKey) putReq.Header.Set("Content-Type", "image/png") putReq.Header.Set("Idempotency-Key", jobID) resp, err := http.DefaultClient.Do(putReq) if err != nil { return fmt.Errorf("upload image: %w", err) } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() if readErr != nil { return fmt.Errorf("read upload response: %w", readErr) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 2 { return fmt.Errorf("upload returned %s: %s", resp.Status, strings.TrimSpace(string(body))) } wait := time.Duration(1<<attempt) * time.Second if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { wait = time.Duration(seconds) * time.Second } select { case <-ctx.Done(): return ctx.Err() case <-time.After(wait): } } return errors.New("upload retry limit reached") } 

This check-then-put sequence is useful, but it is not an atomic lock. Two workers can pass the check at the same time. The durable fix is to allocate the job key once and record a unique constraint in the database, or serialize writes through a queue. If a retry is needed after an ambiguous network failure, repeat the same job decision and inspect the object before attempting another write.

Which storage choice fits private, expiring delivery?

AWS S3 remains the reference choice when the team wants the broadest surrounding ecosystem and is prepared to operate its IAM, bucket policy, lifecycle, replication, and observability configuration. Its presigned URL model matches private downloads well. The trade-off is operational surface area: the storage primitive is familiar, but the policy and account boundaries still need careful design.

Cloudflare R2 is a reasonable fit when the application already lives close to Cloudflare and wants an S3-compatible interface. It can simplify the path to delivery at the edge, but compatibility should be tested against the exact SDK operations the application needs; “S3-compatible” does not mean every service has identical policy, lifecycle, or consistency behavior.

MinIO is the better comparison when control over deployment and data location matters. It gives a team an S3-compatible private object layer it can run itself, at the cost of owning disks, upgrades, capacity, failure domains, and backup verification. That is a platform responsibility, not a checkbox in the upload handler.

Infrai is another option when a plain REST API is preferable to installing and versioning a storage-specific SDK: an HTTP-capable service can call the storage route directly, and the broader platform keeps one authentication surface. That single key also spans the platform's other backend capabilities, so a workflow that generates an image, stores it, and records its job state does not have to coordinate a separate credential and billing surface for each service. Its public discovery surface and runnable examples make the REST contract easier to inspect before wiring a worker.

Here is the comparison I would put in a design review:

Option Access style Best fit Main boundary
Amazon S3 SDKs and REST Broad AWS policy and ecosystem integration IAM and account policy require careful ownership
Cloudflare R2 S3-compatible API Cloudflare-centered delivery Test operation and policy compatibility, not just API names
MinIO Self-hosted S3-compatible API Control over deployment and data location The team owns disks, upgrades, backups, and failure domains
Infrai Plain REST API One credential across backend capabilities No public ACL, versioning, object lock, or independent CORS configuration

The missing byte is the incident.

Keep it private.

The boundary matters more than the feature count. For private application downloads, these limitations can be acceptable if the application owns authorization and the database owns concurrency. They are disqualifying for a static website that needs permanent public links, WORM retention, strict conditional writes, automatic cross-region replication, or hourly expiry.

What do we verify before releasing a link?

Verification should be boring enough to put in a runbook. After the upload returns success, issue a metadata read and compare the expected content type, key, and application record. Only then create the expiring download URL. On a failed upload, leave the database job in a retryable state and do not hand the client a guessed URL. I would also retain the request identifier and the job identifier together, because a later investigation needs to distinguish a rejected write from a write that completed while the client lost its connection. That is a small amount of durable context, but it turns an ambiguous retry into a lookup. The worker can then decide whether to reuse the existing object, retry the same idempotent operation, or mark the job for review without guessing what happened.

That distinction is the runbook.

Watch for four signals: an upload error with no matching object, an object with no completed job, a repeated key collision, and download requests after the authorization deadline. Alerting on those relationships catches more than an aggregate request-success percentage does.

Rollback is deliberately narrow. Revoke or stop issuing the link, mark the job failed or pending, and delete the object only when the database record confirms it is an orphan. Do not use a broad prefix delete as cleanup. Lifecycle rules are useful for eventual retention cleanup, but the shortest supported expiry here is one day, and incomplete multipart fragments do not receive automatic cleanup rules.

The decision rule is therefore: choose the simplest S3-compatible boundary that meets the access-control contract, keep every object private, and make the application prove ownership before delivery. Streaming reduces unnecessary buffering. It does not solve authorization, duplicate delivery, or recovery by itself.

References