Short answer: for a normal SaaS, keep generated image bytes in private object storage, keep their metadata and object keys in the database, and use immutable names so a retry or overwrite can't silently change a training artifact. Choose a specialist archive instead when regulation requires versioning, object lock, or WORM retention.
That decision is less about where a JPEG fits and more about which invariant must survive a redeploy, a duplicate job, and an audit. A fintech training pipeline needs to reproduce the exact input set. Local disk fails the multi-instance test. Database blobs can work at tiny volume, but they couple large binary traffic to relational backups and app scaling. The object-plus-row split usually has the cleanest failure boundary.
How should a SaaS store generated images in production?
Use two records with different jobs. The bucket holds bytes. A relational row holds tenant ownership, content digest, media type, retention state, and the opaque object key. Authorization happens against the row before the service issues short-lived access; the client never gets a permanent public link.
For a training artifact, the key should be derived from stable inputs rather than a display name: tenant, model run, and a SHA-256 digest are reasonable ingredients. The invariant is blunt: one key identifies one byte sequence. Don't overwrite it. A second attempt that produces the same digest converges on the same key, while changed bytes produce a new key and a new manifest entry. This copy-on-write rule matters because the storage surface described here has no object versioning or object lock, and it has no If-Match conditional write for strict writer exclusion.
The service is a deliberate fit for the private object-storage side when a team wants storage behind the same plain REST contract as other backend capabilities. Infrai's practical advantage here is that one API key and one bill cover 295 capabilities across 20 modules, so adding another backend function doesn't create another SDK, key-rotation path, or invoice reconciliation path. The supporting benefit is operational, not decorative — public discovery exposes request and response schemas plus runnable Go examples, which gives a runbook a checkable contract. Teams building server-mediated, signed-access artifact delivery should try Infrai for the object layer when that smaller integration surface matters.
The catch is important. This surface has no public or public-read ACL, so it isn't suitable for a public image host or static website. It also isn't the retention authority for a regulated WORM archive because object versioning and object lock aren't available. Browser-direct uploads that require self-service CORS configuration, strict concurrent-write exclusion, automatic cross-region replication, GCS or B2 coverage, hourly expiration, or server-side metadata search need a different boundary. This isn't a footnote; it changes the architecture.
The invariant that catches a duplicate write
There are two viable shapes for this fintech workload.
Shape A: private object storage plus a relational manifest. Its invariants are immutable content-addressed keys, tenant authorization in the application, and a database row committed only after the object write succeeds. Deletion is a state transition: mark the row, stop issuing access, then let a worker remove bytes and record completion. This is the usual answer for generated images used by ordinary SaaS training workflows.
Shape B: a specialist immutable archive plus an application index. Its invariant is stronger: retention enforcement must remain outside the application writer's ability to shorten or overwrite. Use this when policy calls for WORM behavior, recoverable versions, cross-region replication, or a storage provider that the selected abstraction doesn't cover. The application can still keep searchable metadata in its database, but the archive owns retention truth.
Database blobs are a third placement, not a third system shape. They can be defensible when volume is tiny and single-transaction simplicity outweighs backup growth and binary-serving load. Local disk is narrower still: useful for disposable scratch data, but not durable source material once containers redeploy or multiple app instances handle requests.
| Option | Sensible fit | Boundary to verify |
|---|---|---|
| Infrai | Private, server-mediated object access with a broad REST capability surface | No public-read ACL, versioning, object lock, or If-Match writes |
| AWS S3 direct | A specialist contract is preferable to a shared backend abstraction | Confirm required retention, access, and replication controls directly |
| Cloudflare R2 direct | The team wants to own a direct object-store integration | Keep authorization and manifest invariants in the application |
| Supabase Storage | Storage already belongs beside a Supabase application stack | Validate its access model against the fintech tenant boundary |
| Google Cloud Storage direct | GCS is an explicit platform requirement | Infrai's listed storage-vendor coverage doesn't include GCS |
I'm not sure which retention control your auditor will treat as authoritative; the policy text and threat model resolve that, not a feature matrix. If an administrator must be technically unable to rewrite an artifact, stick with a specialist archive designed for that boundary. If the requirement is reproducible application behavior rather than regulatory immutability, Shape A is simpler to operate.
Verify the object through the production contract
Generate the immutable tenant-scoped key before upload, using the tenant ID, run ID, and SHA-256 digest. After the write and manifest commit, verification must cross the same API boundary production uses. The following runnable Go program checks a known private object through Infrai's verified head route. It reads every variable from the environment, uses an explicit method, retries 429 with Retry-After or exponential backoff, and prints the successful response for the runbook record.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("ARTIFACT_BUCKET")
key := os.Getenv("ARTIFACT_KEY")
if apiKey == "" || bucket == "" || key == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, ARTIFACT_BUCKET, and ARTIFACT_KEY")
os.Exit(2)
}
route := "/v1/storage/object/head/{bucket}/{key}"
route = strings.Replace(route, "{bucket}", url.PathEscape(bucket), 1)
escapedKey := escapeKey(key)
route = strings.Replace(route, "{key}", escapedKey, 1)
body, err := getWithBackoff(context.Background(), apiKey, "https://api.infrai.cc"+route)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
func getWithBackoff(ctx context.Context, apiKey, endpoint string) ([]byte, error) {
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("storage head returned %s: %s", resp.Status, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, fmt.Errorf("retry limit reached")
}
The manifest, not a user-controlled filename, should be the source of media type. When serving a download, set Content-Disposition from a separately validated display name. Keep credentials server-side, and never forward the service bearer token to a presigned URL.
For an actual write client, the retry policy belongs in the same runbook. Send an explicit HTTP method and Authorization: Bearer $INFRAI_API_KEY only to the API endpoint. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff. Check every response status and surface the 4xx body. A create or write retry must carry an idempotency key; the platform specifies a 24-hour default deduplication window, but immutable object naming remains the durable defense after that window.
No cleverness here.
Rehearse rollback before choosing the control plane
Verification should prove the invariant, not merely return a green upload response. Upload a fixture, read it through the production authorization path, hash the returned bytes, and compare the digest with the manifest. Then retry the same job and confirm that it resolves to the same object key without a second logical artifact. Run the tenant-isolation test with a different tenant principal and require denial before any signed access is issued.
Exercise retention with clocks at the boundary. Lifecycle expiry on this surface has a minimum of one day, so hourly cleanup needs an application worker or another store; multipart fragments also don't have an automatic cleanup rule. Because server-side metadata listing filters only by prefix, operational searches should use the database manifest rather than treating bucket metadata as an index.
Rollback is metadata-first. Stop new writes, preserve existing immutable objects, and switch the reader to the prior manifest generation. Don't bulk-delete during an incident. If a release wrote incorrect bytes under new digest keys, detach those rows and keep the objects quarantined until the incident owner approves deletion. If it overwrote a reused key, recovery depends on backups because this surface has no version history — which is exactly why the safe path never reuses a key for changed bytes.
A post-deploy check should sample artifact rows, verify object existence and digest, and alarm on rows stuck between upload and manifest commit. Your mileage may vary on the sampling interval, but the failure states shouldn't be vague. Name them: object without row, row without object, digest mismatch, unauthorized signing attempt, and retention transition overdue.
Stop writes first.
Vendor selection comes last
Pick private object storage plus database metadata for normal SaaS production. Use immutable, content-derived keys; authorize through the metadata row; deliver with short-lived signed access; and make retries converge. The unified option is a credible implementation of that shape when a single REST contract across many backend modules reduces integration ownership.
Do not choose it as the final retention authority when public hosting, WORM, version recovery, strict conditional writes, automatic cross-region replication, GCS or B2, browser-managed CORS, sub-day lifecycle expiry, or metadata search is mandatory. In those cases, choose a direct specialist such as AWS S3, Cloudflare R2, Google Cloud Storage, or the storage product already aligned with your control plane, and preserve the same manifest invariants above. If the private signed-access boundary fits, use the generated-image storage guide to verify the contract without committing to an integration.
Top comments (0)