Short answer: for a logistics SaaS that serves private report images, keep image bytes and generated thumbnails in object storage, keep authorization and retention state in the database, and issue short-lived signed URLs only after an application-level permission check. The important design decision is the deletion record, not the storage brand.

That distinction matters for a customer who asks for a report to disappear. A database row can say “deleted” while an original, a 320-pixel preview, a worker's temporary output, and an old browser URL still exist elsewhere. I would define the retention boundary first, then choose the byte store that can fit it. The question is not only where the bytes live; it is which system can prove that each copy was found, revoked, and removed.

Deletion is a feature.

What does a retention-first image design need to guarantee?

Start with an inventory. For every uploaded image, record the customer, report, object keys, content type, dimensions, processing state, creation time, retention deadline, and deletion time. The database is the system of record for those facts. Object storage is the payload store; its listing operation should not become the application's authorization database.

For a report belonging to customer c-184, a deliberately boring layout might look like this:

from dataclasses import dataclass @dataclass(frozen=True) class ReportImage: original_key: str thumbnail_keys: tuple[str, ...] def make_keys(customer_id: str, report_id: str, widths: tuple[int, ...]) -> ReportImage: segments = (customer_id, report_id) if any(not value or "/" in value for value in segments): raise ValueError("identifiers must be non-empty path segments") if any(width <= 0 for width in widths): raise ValueError("thumbnail widths must be positive") prefix = f"reports/{customer_id}/{report_id}" return ReportImage( original_key=f"{prefix}/original.webp", thumbnail_keys=tuple(f"{prefix}/{width}.webp" for width in widths), ) 

The deletion job should read the database record, mark it as deleting, remove every recorded variant, and only then mark the record deleted. A periodic reconciliation job can find objects under known prefixes that have no active record. That is cleanup, not authorization. Keep an audit event with the actor, reason, deadline, and completion result so a support engineer can answer what happened without searching raw bucket listings by hand.

Do not confuse expiration with erasure. A storage lifecycle rule may be useful for ordinary thumbnail cleanup, but a policy that requires an image to vanish in hours needs an explicit deletion workflow and a test that observes the actual object. Browser caches, application caches, queues, temporary files, and backup retention have to be included in the policy's definition of “deleted.”

How should private user images, signed URL delivery, and database blobs fit together?

The application should authenticate the customer, load the report-image row, verify ownership, confirm that the requested variant is active and within its retention window, and then mint a short-lived signed URL. The browser receives that URL and no storage credential. This keeps authorization in a place that can understand users and reports instead of asking the byte store to infer business policy from a key prefix. It doesn't need to be elaborate, but the check must happen before every URL is issued, including for a thumbnail that appears harmless.

A signed URL is a delivery credential with an expiry, not proof that the image is public. Keep its lifetime short enough for the workflow, and avoid putting long-lived URLs into report records or logs. Redact query strings in request logs; signed URLs commonly carry authorization material in their query parameters.

Database blobs remain a reasonable choice for a very small system whose strongest requirement is one transactional record containing both metadata and bytes. They become a poor default when the same image needs several resized forms, independent delivery traffic, background processing, and retention checks. The binary workload then shares connection pools, backups, replication, and operational limits with the metadata workload. Object storage has a different failure and recovery model, so the split has to be deliberate.

The catch is that object storage does not remove application work. The database-to-object relationship is eventually coordinated by jobs and state transitions, and a signed URL can outlive a user's permission change until its expiry. For highly transactional binary updates, or when a product cannot operate an asynchronous processing path, database storage may be the less complicated choice.

Failure modes that change the storage decision

Deletion races are easy to miss. Suppose a resizing worker finishes after a customer deletion request. If it writes a thumbnail and no state transition checks the deletion marker, the object returns after the cleanup job. Make the worker re-read the image state before publishing, write to a generation-specific key, and let one database transition choose which generation is active. A later reconciliation pass should remove generations that never became active. In a real queue this sequence can cross several retries: the worker may have read “processing,” the customer may have completed deletion, and the worker may only regain a connection after cleanup has already reported success. The safe publication step therefore needs to reject a stale generation, while the cleanup step needs to remain repeatable. A retryable deletion is not a failed deletion; it is a visible state with an owner and a next attempt.

Small state machines beat hopeful ordering.

Overwrites are another quiet risk. If the storage system has no object versioning or object lock in the integration under consideration, an accidental overwrite is not a dependable recovery mechanism and cannot provide WORM retention. Immutable keys help with auditability, but they do not replace an independent backup or retention system when regulation requires immutability.

Thumbnail jobs should be idempotent. A retry after a timeout must not create a second active variant or silently replace a newer transformation. Store an input digest or processing generation, give each output a unique key, and make the database update conditional on the job still owning that generation. A worker that receives a 403 should treat it as an authorization result and stop publishing; it should not turn the response into a new public path. The exact coordination primitive belongs in the database or queue; storage listing is not a lock.

Multipart uploads need an explicit inventory too. Track incomplete upload IDs and clean them on a schedule. If the chosen lifecycle policy has a minimum expiry of one day, it cannot express an hourly deletion promise. That is a capability boundary, and it should be recorded in the design review rather than hidden in an operations runbook.

Regional requirements are equally concrete. “Europe” can mean residency, recovery location, support jurisdiction, or latency. Ask which region holds originals, thumbnails, logs, backups, and derived caches. Automatic cross-region replication and bulk migration are separate capabilities, not consequences of choosing object storage. Your mileage may vary because the answer depends on the customer's contract and recovery objective.

A practical comparison for a private report service

Compare the options only after writing down the invariants. The following table is a decision aid, not a ranking.

Choice Fits when Main trade-off
Database blobs The dataset is small and atomic metadata-plus-bytes writes matter most Image delivery, backups, and thumbnail variants stay coupled to database capacity
Object storage with application signing Images are larger, variants are numerous, and authenticated reads are the normal path Deletion, reconciliation, and asynchronous processing need explicit state machines
Provider-native edge delivery Anonymous or globally cached assets are a core requirement Public caching changes the authorization and deletion model; private report images need careful cache controls
Self-hosted object storage The team needs direct control of placement and operations The team owns durability, upgrades, replication, monitoring, and recovery testing

Pick database blobs when the operational simplicity of one system outweighs the cost of coupling. Pick object storage when the application can own the metadata workflow and needs a separate path for binary delivery. Pick a public delivery architecture only for assets whose access policy is genuinely public. A private logistics report is not that asset merely because a CDN could serve it quickly.

Roll out the retention and delivery contract

Begin with one report, one original, and one thumbnail width. Test an authorized read, an unauthorized read, an expired signed URL, a deletion during processing, a repeated worker attempt, and a cleanup retry. Inspect the object store and the database after each test; a green HTTP response is not evidence that retention works.

Then add the operational controls: metrics for pending and deleting records, alerts for objects without records, a dashboard for failed deletion attempts, and a runbook for restoring an image from the independent backup path. Test restoration separately from upload. It is a different operation with different permissions.

Keep retention durations in configuration with an owner and a change log. Make the report API refuse a signed URL for a record past its deadline even if an object still exists, and make deletion revoke future URL issuance before byte cleanup begins. This ordering closes the application authorization path first while the slower physical cleanup catches up.

The least complex design that meets the requirement is the one whose deletion proof can be explained from records, jobs, and tests. Storage selection comes after that.

References

Further reading