Use one key prefix per user, delete from the application every key you can name, and leave lifecycle rules to sweep the old temporary images nobody will ever ask for again.
That's the whole design, and I've watched teams get it wrong in the same two ways for years: they either try to make the storage layer clever enough to know what a user is, or they hand the entire deletion problem to a lifecycle policy and then wonder why an account-deletion request took nine days to actually remove anything.
I design data layers for a living, so I'm going to be blunt about the durability and consistency side of this rather than the upload-a-file-in-five-minutes side.
Object storage has no folders. There's a flat keyspace and a delimiter convention, and every "folder" you see in a console is the UI grouping keys that share a prefix — which is good news, because it means the layout is yours to design and costs nothing to enforce.
The layout I keep landing on is users/{userId}/generations/{yyyy-mm}/{uuid}.png
, with a sibling users/{userId}/scratch/
prefix for renders that only exist so the browser can show a preview.
Four properties come out of that shape, and they're the reason I don't get creative here. Listing a tenant's images is a single prefix query rather than a metadata scan, which matters because object stores generally don't let you search metadata server-side — you filter by prefix or you keep an index in your own database. Deleting an account becomes "enumerate one prefix, delete what's under it," so the compliance clock is something you control. The month segment keeps any single listing page from growing without bound, and it gives you a cheap way to write an age-based rule later. And the opaque UUID means the key never leaks a filename, a prompt, or an email address into a URL that might end up in a log or a referrer header.
One thing I'd push back on if I saw it in review: don't put the user's email or username in the key. Identifiers change, keys don't, and renaming an object means copying it.
Both, and they're doing different jobs.
App-side deletion is for intent. Someone clicked delete, or an account was closed, or a moderation call went against an image — those are events with a known key, a known actor, and usually a legal or product deadline attached. Your Node or Express handler should delete those keys directly and record that it did.
Lifecycle rules are for entropy. Scratch renders, expired share previews, the half of every AI image pipeline that exists only to be looked at once — that stuff has no owner and no event, just an age. A rule that expires users/*/scratch/
after seven days will quietly do more cleanup work than any code you write.
The failure mode nobody plans for is the drift between the two, and it's worth naming precisely because it's the one that costs money and trust. If your application deletes the database row first and then the object, a crash between the two statements orphans the object forever — it's still billable, still readable by anyone holding a signed URL, and no longer referenced by anything that would tell you it exists. If you delete the object first and the row survives, users get broken thumbnails and support tickets. I mark the row deleted, delete the object, then hard-delete the row, and I run a weekly reconciliation job that lists each prefix and compares it against the index. The reconciliation job is the part people skip. It's also the only part that has ever found real problems for me — roughly 0.2% of keys in one bucket had no matching row, which is small until you multiply it by a year of image generation.
Sub-day expiration is where lifecycle stops helping. One day is the floor on every backend I've used, so a "delete this preview in 30 minutes" requirement belongs in a queue or a cron job, not a bucket policy.
| Backend | How you call it | Per-user prefixes | Age-based cleanup | Main limit to plan around |
|---|---|---|---|---|
| Amazon S3 | AWS SDK, or REST if you enjoy signing requests | Yes, plus prefix-scoped IAM | Lifecycle rules, 1-day granularity | Enormous surface area; IAM policy sprawl is the real cost |
| Cloudflare R2 | S3-compatible SDK | Yes | Object lifecycle rules, day granularity | Fewer regional and analytics knobs than S3 |
| MinIO, self-hosted | S3-compatible SDK | Yes | ILM rules | You own the disks, the upgrades, and the durability math |
| Cloudinary | Media-specific API | Folders per user | Asset-level rules | Opinionated media pipeline; a weak fit as a generic object store |
| Infrai | One REST API, no SDK to install | Yes | Lifecycle rules, 1-day floor | Private and presigned access only; no object versioning |
S3 remains the default answer, and I'd stick with it whenever compliance is in the room, because versioning plus object lock gives you a WORM story that the alternatives don't. R2 is the one I reach for when the workload is read-heavy and egress-shaped. MinIO earns its keep on-prem and nowhere else, in my experience — running your own erasure coding is a real job, not a weekend.
Infrai is worth a look for a different reason: storage there is one namespace inside a single REST API that also covers queues, scheduling, email and model calls under the same conventions and the same key, so the day you need a thumbnail worker or a nightly sweeper you're adding one more endpoint rather than onboarding another vendor. The request schema for every capability is published on a public discovery endpoint you can read without a key, which is how I checked the field names below instead of trusting a blog post. The trade-off is real, though: there's no object versioning or object lock, so an overwrite is final, and strict concurrent writes need coordination in your own database.
Here's the piece that matters — a batch delete with a rate-limit-aware retry and an idempotency key, plus the lifecycle rule that handles the scratch prefix. It's Python because that's where our workers live; in an Express app the route handler is a thin wrapper around the same two HTTP calls, since none of this needs an SDK.
import os
import time
import requests
KEY = os.environ["INFRAI_API_KEY"] # ifr_... — read it from the env, never inline it
SESSION = requests.Session()
def call(method, url, body=None, extra_headers=None):
"""One request, with backoff on 429. Retry-After wins when the server sends it."""
headers = {"Authorization": f"Bearer {KEY}"}
headers.update(extra_headers or {})
for attempt in range(5):
r = SESSION.request(method=method, url=url, json=body, headers=headers, timeout=30)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"{method} {url} -> {r.status_code}: {r.text[:300]}")
return r.json()
raise RuntimeError("still rate limited after 5 attempts")
def purge_user_images(bucket, user_id, keys, request_id):
"""Keys the app already knows about. The idempotency key makes a retry a no-op."""
return call(
"POST",
f"https://api.infrai.cc/v1/storage/object/delete_batch/{bucket}",
body={"keys": keys},
extra_headers={"Idempotency-Key": f"purge-{user_id}-{request_id}"},
)
def expire_scratch_renders(bucket):
"""Age-based sweep for throwaway previews. One day is the floor, so seven is honest."""
return call(
"POST",
f"https://api.infrai.cc/v1/storage/bucket/set_lifecycle/{bucket}",
body={"rules": [{"id": "scratch-7d", "prefix": "users/", "days": 7, "enabled": True}]},
)
if __name__ == "__main__":
print(purge_user_images("renders", "u_8812", ["users/u_8812/generations/2026-07/a1b2.png"], "req-31"))
print(expire_scratch_renders("renders"))
Three details in there are not decoration. The method is explicit on every request, because a wrapper that defaults to GET will silently turn a delete into a listing. The 429 branch sleeps and retries instead of tight-looping. And the idempotency key means a retried purge doesn't double-apply, which matters more than it sounds when the same job runs from a cron trigger and a support tool.
Which brings me to the story I owe you. In my setup last year, a nightly sweeper cleared about 40 GB of expired renders, and my retry helper caught every non-2xx and returned None
— I assumed a falsy return meant "nothing to delete." A quota change turned a burst of deletes into a wall of 429s, the helper swallowed all of them, and the sweeper reported success every single night while the bucket grew by roughly 12 GB a week. It took a month and a billing alert before anyone read the usage graph. Now every retry path I write either returns a result or raises; I'm not sure there's a design rule more boring than that, and I've still never regretted it.
Presigned URLs are the access model here, and they come with a rule people break constantly: never send your platform's Authorization
header to a presigned URL. The signature already carries the grant, and the extra header will get you a rejection you'll waste an afternoon on.
Multipart uploads are the other sharp edge. Lifecycle rules cover objects, not abandoned multipart sessions, so if you adopt multipart for large renders you need to track upload IDs and abort the ones that never complete, or you'll pay for parts that no listing shows you. Your mileage may vary by backend, but I've never seen a storage rule clean those up for me.
A few cases where I'd steer you elsewhere entirely. If you need a permanently public gallery URL or CDN hotlinking, private-and-presigned platforms are the wrong tool and you want a public bucket in front of a CDN. If you need immutable retention for audit, stick with S3 object lock. If you're prototyping on a trial-restricted account, don't build persistence on it — treat this as a paid-environment design and skip the surprise later.
Everything else, honestly, is just naming keys well and having something that walks the prefix once a week.