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

Healthtech AI Image Generation App Storage: Private Originals, Variants, and Cleanup

Short answer: treat originals, thumbnails, variants, and export files as governed records with different owners, keep the bucket private, and make signed links the final authorization step rather than the storage model.

Short answer: treat originals, thumbnails, variants, and export files as governed records with different owners, keep the bucket private, and make signed links the final authorization step rather than the storage model.

For a healthtech application that stores AI-generated product images, the most expensive storage mistake is usually a governance mistake: a thumbnail is treated as the source, a tenant can retain an export after access is revoked, or a cleanup rule deletes bytes before the product knows what they mean. Large-file throughput matters, but it only matters after the system can prove which bytes belong to which tenant and which state transition authorized them.

This runbook uses reliability and migration as the decision axis. It is aimed at a platform team that has to own the SLO, the on-call burden, and the evidence trail when a customer asks where an image came from. The exact object-store product can vary; the contracts below should not.

Start with an asset ledger, not a bucket layout

Every durable image needs a database record before it becomes visible to a user. At minimum, the record should carry tenant ID, asset ID, object key, content type, byte count, dimensions, generation state, checksum, and retention class. Store prompt, model, catalog status, and creator as searchable metadata in the database. Object listing is not a query index.

Use distinct object classes even if they share one private bucket:

Class Example key What it means Retention owner
Original originals/tenant-17/asset-8c1/source.png The source that may be needed for regeneration Asset and recovery policy
Approved variant variants/tenant-17/asset-8c1/2048.webp A durable presentation or export form Asset state
Thumbnail thumbnails/tenant-17/asset-8c1/256.webp A replaceable derivative Derivative policy
Work file work/tenant-17/job-52/part-0007 An intermediate attempt Job expiry policy

An object key is a locator, not a permission. Authorization still belongs in the application, where the tenant relationship and asset state can be tested. A signed URL should identify one object or one completed export, never an arbitrary prefix. The browser should receive it only after the application has checked tenant membership, asset visibility, and link expiry policy.

Keep the state transitions explicit: uploaded, generated, approved, withdrawn, exporting, complete, and expired are examples of states, not a license to let a lifecycle rule infer meaning from age. A thumbnail can be regenerated. An original may be the only input for a later model run. A work file belongs to an attempt. Those are different operational obligations.

What should an image generation app verify before moving originals, thumbnails, and variants?

Migration is where a tidy storage pattern meets real data. Before changing prefixes, bucket settings, or retention rules, build a report-only reconciler that compares the asset ledger with object inventory. It should count missing objects, unowned objects, objects in the wrong class, duplicate checksums, and exports whose snapshot points at an asset that is now withdrawn. In a real cutover, the dangerous record is not the obvious missing file; it is the apparently healthy object that has the wrong tenant in its ledger row, because a copy job can preserve bytes and still widen access. A second trap is a thumbnail regenerated between inventory and migration: if the worker uses a mutable β€œlatest” reference, the new ledger can point to bytes that were never part of the original asset state. Freeze the source reference, copy by stable asset ID, and make the verification record carry the source checksum, destination checksum, and state version. That gives the on-call engineer something stronger than β€œthe copy returned success” when a customer asks which image was exported.

Do not make deletion the first migration write. Require every candidate action to include tenant ID, object class, age, checksum when available, database reference, export reference, and the reason it is eligible. An unexplained ownership mismatch is a stop signal. This report is also useful during an incident because it separates a missing byte from a missing database relationship.

Pause here.

A safe migration has a narrow write path: copy one object, verify its checksum and metadata, write the new ledger reference, then mark the old object as a candidate for later removal. The application should read from the new reference only after the verification record exists. If the copy is interrupted, retry by stable asset ID; do not create a second customer-visible asset because a worker restarted.

Here is the core boundary in Go. The storage adapter owns streaming and checksum verification; the caller owns the already-authorized migration plan. The adapter must still validate every reference, because a caller-side filter is not a tenant isolation control.

package main

import (
    "context"
    "fmt"
    "io"
)

type ObjectRef struct {
    TenantID string
    Key      string
}

type ObjectStore interface {
    Open(ctx context.Context, tenantID, key string) (io.ReadCloser, error)
    }

type ObjectWriter interface {
    Put(ctx context.Context, tenantID, key string, src io.Reader) error
}

func migrateObject(ctx context.Context, store ObjectStore, writer ObjectWriter, ref ObjectRef) error {
    if ref.TenantID == "" || ref.Key == "" {
        return fmt.Errorf("invalid object reference")
    }

    body, err := store.Open(ctx, ref.TenantID, ref.Key)
    if err != nil {
        return fmt.Errorf("open %s: %w", ref.Key, err)
    }
    defer body.Close()

    if err := writer.Put(ctx, ref.TenantID, ref.Key, body); err != nil {
        return fmt.Errorf("write %s: %w", ref.Key, err)
    }
    return nil
}

In production, Put should write to a migration-specific destination and return verified size and checksum metadata. The example leaves that provider detail behind the interface on purpose. The ledger update must be idempotent, and a second attempt must converge on the same destination rather than append another variant.

Signed links are a delivery control, not a retention policy

A private bucket closes one exposure path, but it does not decide who may see an image six hours later. The application should create a link only for a completed, visible object and should bind the authorization decision to the tenant and asset IDs. Record who requested it, which object version was selected, and when the link expires. Do not log the full signed URL if it contains credentials or authorization material.

Exports need one more boundary. Create an export row with tenant ID, stable export ID, snapshot boundary, selected bytes, status, and expiration. Select assets from that snapshot, stream originals or approved variants into a staging object, and publish the final object only after the archive closes successfully and its metadata is recorded. A partial archive remains inaccessible and job-specific.

This is the large-file throughput rule: keep the byte stream out of the request handler. A worker should apply backpressure, bound concurrent reads, and avoid buffering a complete original in memory. Estimate concurrency from egress, average object size, archive CPU, and maximum export duration, with headroom for image generation and ordinary reads. A queue full of exports should delay exports, not starve interactive image creation.

Measure bytes and queue age, not request count alone. Ten thumbnails and ten original-resolution images are different workloads. The export SLO should include queue age, time to first byte, completion latency, bytes read and written, retry count, worker memory, and authorization failures. I'm not sure a useful latency target can be chosen without traffic shape and customer promise; establish those inputs before setting the number.

Cleanup needs a proof step and a rollback path

Lifecycle cleanup is a backstop. It should not be the only process that knows whether an object is safe to remove. Work files can have a job-aware cleanup worker for short retention, while a bucket lifecycle policy handles the broad expiry class. Approved variants need a product retention decision. Originals need a recovery decision before a deletion rule is enabled.

Run the reconciler in dry-run mode through a full observation period. Enable deletion for expired work files first, watch deletion counts and retained bytes, and expand one class at a time. If the report contains objects with no accountable tenant or a ledger reference that cannot be explained by a state transition, stop the rollout.

Rollback means stopping new destructive actions and preserving evidence. Disable the cleanup worker or rule, pause producers that create the affected class, retain the reconciliation report, and restore the prior read reference if a migration has already changed it. A rollback cannot bring back bytes that were permanently deleted, so recovery and restore testing belong in the design review rather than in the incident plan.

Test the ugly cases before production: a tenant resolving another tenant's key, a revoked asset requesting a new link, a duplicate export ID, a missing original, a replaced thumbnail, a canceled job, and an object larger than the worker memory budget. Include a migration retry after the destination exists. Record the expected state transition and checksum for each test so a green HTTP response is not mistaken for data correctness.

Choosing the boundary your team can actually operate

The comparison is about ownership of hard guarantees, not which interface looks familiar.

Pattern Fits when The catch; choose another boundary when
Managed private object storage plus an application ledger The team needs durable large files, private access, lifecycle controls, and a smaller storage on-call surface Required replication, recovery, or compliance guarantees are unavailable at that boundary
Direct provider integration Provider-specific storage controls are central and the team accepts that coupling The platform cannot afford separate credentials, APIs, and operational rules for each backend
Self-hosted object storage Data placement, network locality, or control requirements justify operating storage The team lacks capacity for disks, replication, upgrades, repair, and storage-specific SLOs
Database-backed blobs Files are genuinely small, transaction atomicity matters more than independent byte throughput, and export volume is low Large files and concurrent exports would compete with transactional I/O and connection capacity

For this healthtech scenario, a private object boundary with an application-owned metadata layer is a reasonable default because it separates large bytes from transactional rows and makes tenant authorization testable. It is not suitable when immutable retention, cross-region recovery, or a required compliance control cannot be supplied by that boundary. Choose a storage system with those guarantees, or add an external recovery and retention layer. Stick with database-backed storage when the files are small enough that atomic coupling is the stronger requirement.

The final review question is simple: can the team explain every retained object, every signed delivery, and every deletion from records it controls? If the answer is no, changing vendors will not repair the design. Fix the ledger and the state transitions first.

References

πŸ“° 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.