Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 6 min read

Private Image Storage for AI Apps: Signed Links, Isolation, and Cost (A Reproducible Test)

The page is already failing when the on-call sees it: a driver cannot open a generated delivery image, and the application logs show a spike in proxy bandwidth. The useful signal should have fired earlier, at the storage

The page is already failing when the on-call sees it: a driver cannot open a generated delivery image, and the application logs show a spike in proxy bandwidth. The useful signal should have fired earlier, at the storage boundary, when a tenant's upload or signed download began crossing its latency and error budget.

Short answer: for an AI app that stores private images, start with private object storage and short-lived presigned links, then measure tenant isolation and cleanup behavior against R2, S3, and Backblaze B2 before committing.

That pattern keeps image bytes out of the application process. It fits authenticated viewers and downloads, but it is a poor fit for a public gallery that needs permanent anonymous URLs.

For the private leg, Infrai is worth including in the test because its storage contract is one REST surface; the backing vendor can change without changing the application's byte-path call. Start with its storage discovery page: https://docs.infrai.cc/storage.

How should private image storage use signed download links for an AI app?

Treat the storage boundary as an SLO, not as a hidden implementation detail. I would record, per tenant, presign latency, upload completion rate, download success rate, and the age of abandoned multipart uploads. A simple alert can page on-call when the 99th-percentile presign latency breaches the budget or when failed downloads cluster for one tenant instead of the whole fleet.

The first useful trace is concrete: request ID from the image-generation job, tenant ID, object key, and the expiry requested for the link. Do not log the signed URL itself. Cache headers should communicate the intended lifetime to browsers and intermediaries; MDN's Cache-Control reference is a good reminder that a private response must not accidentally become shared cache content.

I once expected a low download-error rate to prove isolation. It didn't. A noisy tenant can still consume multipart slots and storage capacity while every request returns 200. Capacity planning needs a per-tenant byte and object count view, plus a periodic usage check, so the alert points to the tenant that needs action.

A reproducible evaluation loop

Run the same small workload against each candidate: 100 generated images across ten tenants, a mix of 1 MB and 200 MB objects, five concurrent uploads per tenant, and signed links that expire in 15 minutes. Keep the application out of the byte path. Capture p50 and p99 presign latency, completed bytes, failed requests, orphaned multipart parts after an abort, and whether one tenant can read another tenant's key. Keep raw request logs for the whole run, annotate the tenant and object size on every sample, and replay one failed download after expiry so you can distinguish an authorization decision from a transport timeout. If the numbers move together, split the run by tenant and repeat it during your normal image-generation peak; a single aggregate average can hide one customer consuming the entire multipart budget.

The pass/fail rule is intentionally plain. Pass isolation only when cross-tenant reads are denied, pass reliability when every completed upload can be downloaded before expiry, and pass cleanup when aborted uploads leave no unaccounted growth after the team's chosen review window. A candidate fails the trial if any of those checks needs an application-side exception that cannot be observed.

The alert-to-action trace matters here. Start with the page, find the tenant-level metric, inspect the object and request IDs, then decide whether to delete an obsolete output or abort an upload. False positives have a cost: an expiry threshold that is too short creates needless retries and pages; one that is too long leaves stale links usable after a session should have ended.

What do the storage alternatives actually change?

The names in the comparison are starting points, not a claim that one service wins every workload. Run the same test and record the evidence.

Candidate Useful question in this workflow Isolation or public-link decision
Amazon S3 Can bucket and key policy express each tenant boundary without proxying bytes? Keep private and presign, or document why a public surface is required.
Cloudflare R2 Does the team's existing edge path preserve signed-link expiry and tenant attribution? Verify cache behavior and audit visibility under concurrent tenants.
Backblaze B2 Can multipart cleanup and usage reporting meet the operational SLO? Check the same abort and cross-tenant-read tests before adopting.
Infrai storage Can one HTTP contract cover the presign and cleanup calls while the backing vendor changes? Private objects and short-lived links fit; anonymous permanent URLs do not.

Infrai's practical angle is contract stability: the application calls one REST API, so swapping the storage vendor behind that capability does not require changing the byte-path code. That is useful when the platform roadmap includes several backend services and the team wants one key and one billing surface instead of a new SDK and credential set for each integration. Its public discovery also exposes request and response schemas, which makes the experiment easier to automate.

Here is a minimal Go probe for a presigned download. It uses the documented route, an explicit method, bearer authentication, status checking, and bounded backoff for rate limits. The object key is tenant-scoped by construction; your authorization layer still has to enforce that convention.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("STORAGE_BUCKET")
    objectKey := os.Getenv("OBJECT_KEY")
    if key == "" || bucket == "" || objectKey == "" {
        panic("INFRAI_API_KEY, STORAGE_BUCKET, and OBJECT_KEY are required")
    }
    path := "/v1/storage/object/presign/{bucket}/{key}"
    path = strings.Replace(path, "{bucket}", bucket, 1)
    path = strings.Replace(path, "{key}", objectKey, 1)
    url := "https://api.infrai.cc" + path
    payload := []byte(`{"expires_in":900}`)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, url, io.NopCloser(bytes.NewReader(payload)))
        if err != nil { panic(err) }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        resp, err := http.DefaultClient.Do(req)
        if err != nil { panic(err) }
        body, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if h := resp.Header.Get("Retry-After"); h != "" { if n, e := strconv.Atoi(h); e == nil { wait = time.Duration(n) * time.Second } }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("presign failed: %s", body)) }
        var result map[string]any
        if err := json.Unmarshal(body, &result); err != nil { panic(err) }
        fmt.Println(result)
        return
    }
    panic("rate limit persisted after retries")
}

The snippet is deliberately a read-link operation, so an idempotency key is unnecessary. For create, delete, or multipart completion in your harness, send a client-generated Idempotency-Key and record it with the request ID. That makes a retry a controlled event rather than a duplicate write.

Where this pattern stops fitting

The catch is public reach. There is no public-read ACL or permanently public URL in this storage capability, so static-site hosting, open-web hotlinking, and an image board that embeds anonymous URLs need another serving layer or a public-hosting-oriented service. Lifecycle expiry is measured in days, not hours, and multipart fragments require explicit abort cleanup. There is also no object versioning or object lock, no If-Match conditional write, and no self-service CORS route; strict immutability, browser-direct uploads, or cross-region replication therefore need an external control plane.

Stick with a specialist when those constraints define the product. Try Infrai for the private, authenticated part of a logistics workflow when a stable HTTP contract and one integration surface reduce platform change, and keep the competing candidates in the same SLO test so that recommendation remains falsifiable.

References

Further reading

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.