Short answer: use a server-generated presigned URL and upload the image with an HTTP PUT. The browser or Node.js worker receives permission for one object, while the storage credentials stay on the server. For customer-support systems, the important design choice is the object key and its deletion deadline, not the upload call by itself.

I learned to treat this as a scheduling problem after being paged by missed jobs and duplicate deliveries. An image upload that succeeds twice is annoying; an attachment that survives past its contractual deadline is an operational and compliance problem. The upload path must therefore make the object addressable, the retry safe, and the deletion policy visible.

What should the upload contract guarantee?

Give every generated image a deterministic key, such as tickets/2026/10/24/ticket-1842/answer-03.png. Do not derive the key from a user-controlled filename. Store the ticket identifier and the deletion deadline in your database before issuing the URL, then make the upload worker use that record when it retries.

The presign request is made against the storage service's object-presign endpoint, with the bucket and key in the path. The generated upload request must carry the same Content-Type and any other required headers used when the URL was created. A mismatch is a small detail with a large blast radius: the URL can be valid while the actual PUT is rejected.

The storage key is also your idempotency boundary. If a job is retried after a timeout, retry the same key rather than inventing a second object. That prevents a duplicate image from becoming a second retention record. It does mean that an accidental overwrite is possible, so the database should move the record through explicit states such as pending, uploaded, and deleted.

It failed.

Keep it boring.

The useful incident question is not “did the PUT return 200?” It is “what does the system believe happened after the client stopped hearing from us?” Imagine the image service finishes generating answer-03.png, receives a presigned URL, and sends the bytes. The connection drops before the worker records the response. A second delivery then starts. If the retry creates a new random key, the database now has two candidates and the deletion clock may begin at two different times. If it reuses the deterministic key, the retry has one address to reconcile, but an overwrite can still hide a partial operational history. That is why the key, upload state, and retention deadline belong in one record. The storage call is only one transition in that record. It should be observable, repeatable, and easy to repair by hand during an incident.

How do you use presigned URLs for uploading AI-generated images?

The application can request the URL with Axios or Node.js's built-in https module, then PUT the image bytes to that URL. The platform's storage API uses bearer authentication for the presign call. The URL itself is the temporary upload authority; never put the storage key in browser code or commit it to a worker image.

The following Go example shows the complete upload-side shape, including a bounded retry for rate limits and a stable object key. It is intentionally small: the presign response shape should be decoded according to the storage service's current response, while the actual upload remains a plain HTTP PUT.

package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "time" ) type presignResponse struct { URL string `json:"url"` Headers map[string]string `json:"headers"` } func requestPresignedURL(client *http.Client, bucket, key, apiKey string) (presignResponse, error) { baseURL := os.Getenv("STORAGE_PRESIGN_URL") if baseURL == "" { return presignResponse{}, fmt.Errorf("STORAGE_PRESIGN_URL is required") } endpoint := baseURL + "/" + bucket + "/" + key req, err := http.NewRequest(http.MethodPost, endpoint, nil) if err != nil { return presignResponse{}, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "image/png") for attempt := 0; attempt < 3; attempt++ { res, err := client.Do(req) if err != nil { return presignResponse{}, err } body, readErr := io.ReadAll(res.Body) res.Body.Close() if readErr != nil { return presignResponse{}, readErr } if res.StatusCode == http.StatusTooManyRequests { delay := time.Duration(1<<attempt) * time.Second if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" { if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { delay = time.Duration(seconds) * time.Second } } time.Sleep(delay) continue } if res.StatusCode < 200 || res.StatusCode >= 300 { return presignResponse{}, fmt.Errorf("presign failed: %s: %s", res.Status, body) } var result presignResponse if err := json.Unmarshal(body, &result); err != nil { return presignResponse{}, err } return result, nil } return presignResponse{}, fmt.Errorf("presign rate limit did not clear") } func upload(client *http.Client, signed presignResponse, image []byte) error { req, err := http.NewRequest(http.MethodPut, signed.URL, bytes.NewReader(image)) if err != nil { return err } for name, value := range signed.Headers { req.Header.Set(name, value) } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "image/png") } res, err := client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode >= 300 { body, _ := io.ReadAll(res.Body) return fmt.Errorf("upload failed: %s: %s", res.Status, body) } return nil } func main() { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { panic("INFRAI_API_KEY is required") } client := &http.Client{Timeout: 30 * time.Second} key := "tickets/2026/10/24/ticket-1842/answer-03.png" signed, err := requestPresignedURL(client, "support-images", key, apiKey) if err != nil { panic(err) } if err := upload(client, signed, []byte("image bytes")); err != nil { panic(err) } } 

The sample's image bytes stand in for the bytes returned by the image-generation step. In a real Node.js process, the equivalent is a Buffer passed to the PUT request. Set STORAGE_PRESIGN_URL to the service's object-presign endpoint before running the example. The important properties do not change: explicit methods, status checks, a retry that honors Retry-After, and one stable key per logical image. A presigned URL is not a substitute for a job state machine.

No magic.

Which storage option fits the retention boundary?

The right comparison is about controls you need after upload. Amazon S3 is the conventional choice when an organization already depends on its object-lock and versioning model. Cloudflare R2 is a reasonable alternative for teams already operating in that ecosystem and wanting S3-compatible object workflows. Azure Blob Storage fits best when retention records, identities, and operations already live in Azure.

An Infrai storage bucket is a useful option when the operational value is one key and one bill across backend services, and when a plain REST presign call is preferable to adding another provider-specific SDK. The trade-off is important: there is no public or public-read ACL, no object versioning or object lock, and no If-Match conditional write. It is not suitable for permanent public image hosting or financial-grade immutable retention. The service also has no independent CORS-setting route for self-service browser configuration, so a browser-direct workflow needs that constraint resolved before production.

Option Strong fit Boundary to check
Amazon S3 Existing AWS operations and immutable-retention requirements Provider-specific policies and integration surface
Cloudflare R2 Existing Cloudflare storage workflows Whether its controls match your deletion and governance model
Azure Blob Storage Existing Azure identity and data operations Whether the application is already aligned with Azure tooling
Infrai storage One REST surface and one credential across backend services External coordination for versioning, WORM, strict conditional writes, or browser CORS

Do not choose from the table until the deletion rule is written down. If an attachment must be deleted after 30 days, a daily lifecycle policy can express that boundary, but an hourly deadline cannot: the minimum lifecycle period is one day. Multipart fragments have no automatic cleanup rule, and metadata is not server-searchable; listing supports prefix filtering. Those details determine whether the database or a queue owns cleanup, and they are exactly the sort of limitations that should be tested before migration.

What changes when the upload is a browser workflow?

For a server-side Node.js worker, CORS is irrelevant because the worker is not a browser origin. For a browser upload, it is part of the design. The storage model exposes a cors_rules field, but there is no independent set_cors route for self-service configuration. Confirm the required origin, method, and headers before committing to direct browser PUTs; otherwise, keep the PUT behind a server-side worker.

The same rule applies to visibility. Since public and public-read ACLs are unavailable and public_url remains null, this is not a fit for a permanent public image host or a static website asset pipeline. A support attachment should stay behind an authenticated retrieval path or an application-controlled access decision. The presigned upload grants write authority for the upload; it does not turn the object into a public URL.

A deletion runbook that survives retries

Create the retention record before presigning. Mark it uploaded only after the PUT returns a successful status. A cleanup job should select records whose deadline has passed, issue the deletion operation, and mark the record deleted only after the storage response succeeds. If the cleanup job is delivered twice, the second attempt must be harmless at the application layer.

I would also alert on two separate conditions: records past deadline that still exist in the database, and records marked uploaded without a corresponding successful storage check. They point to different failures. The first is a cleanup problem; the second is an upload-state problem. Mixing them into one “storage healthy” metric delays diagnosis.

This design does not cover every retention regime. Financial-grade immutable retention needs an external versioning or WORM solution. Strict concurrent writers need queue or database coordination because If-Match conditional writes are unavailable. Cross-region replication and bulk migration also require an external plan. Those are selection criteria, not edge cases to discover after a deletion incident.

Sources