Short answer: for a normal small US or EU SaaS, keep avatar files in private object storage and store only the tenant-scoped object key and metadata in the database. Database blobs enlarge backups and add application load. Server-local disk stops being simple as soon as another app instance can receive the next request.
For a customer-support product, I would make replacement rather than upload the center of the design. A support agent needs the current tenant avatar, while an operator may need to restore a selected snapshot. New object keys make both actions explicit. They also avoid depending on overwrite history that the storage layer does not provide.
Ship the boundary, then ship the feature.
Tenant data rules begin with an avatar ledger
The database should be the control plane, and the bucket should hold the bytes. A row answers which tenant owns an avatar, which object key is current, and which snapshot was selected. The object store answers whether those bytes can be written or retrieved. Keeping those jobs separate means a database backup does not absorb every image, and replacing an app instance does not require copying its volume.
That split matters to a one-person SaaS because engineering time has a revenue-per-hour cost. I want tenant authorization tests in the application, metadata constraints in the database, and file handling in a service built for files. I don't want an image upload to turn every database backup into a media migration.
My first instinct for a tiny product is often local disk. It is one directory and almost no setup. The catch appears at instance number two: a request can land on a machine that never received the file. A shared volume moves the problem, but it doesn't remove the operational work. Database blobs preserve familiar transactional controls, yet they increase backup size and application load. Object storage adds a network boundary and another state transition, but it keeps the binary payload out of the application database and works across app instances.
That is the trade.
The products below are not interchangeable. PostgreSQL keeps bytes near row-level ownership data but makes the database carry media through backups and application reads. Amazon S3, Cloudflare R2, and MinIO represent object-storage choices with different operating models. A discovery-driven REST service is another private-object option, and its main fit here is integration simplicity rather than a claim that it wins every storage workload.
| Option | Isolation boundary | Operational consequence | Better fit when |
|---|---|---|---|
| PostgreSQL blobs | Database row and database authorization | Media increases backup size and app load | Binary volume is genuinely tiny and one database transaction is the overriding need |
| Amazon S3 | Private objects plus application/IAM policy | Object lifecycle tooling is documented and separate from app instances | You need the established S3 ecosystem and its lifecycle controls |
| Cloudflare R2 | Private objects behind an S3-compatible interface | Application instances do not own the files | Your existing design is centered on its object-storage interface |
| MinIO | Private objects on infrastructure you operate | Your team owns the storage deployment and its maintenance | Self-managed infrastructure is a requirement |
| Infrai storage | Private objects plus application-side tenant checks | Its self-describing, plain REST API works without installing an SDK, and one key covers 295 routes across 20 modules | A small team wants runnable examples and one credential across backend capabilities |
I would still choose Amazon S3 when its broader storage controls are the requirement, and MinIO when self-management is the point. PostgreSQL blobs can be reasonable for a truly tiny data set where atomic coupling outweighs backup growth. Local disk belongs to disposable single-instance artifacts, not tenant media that must survive a deployment.
How should a small EU/US app govern user avatar uploads?
Use a private bucket and create a fresh key for every avatar revision, such as tenants/{tenantId}/avatars/{avatarId}.webp. Validate tenant membership before accepting bytes. After the write succeeds, record the object key, content type, byte count, and checksum in the database, then move the tenant's current-avatar pointer in a transaction. Reads must begin with the same ownership check.
Do not overwrite the current object. Upload the replacement under a new key, commit the pointer, and delete the old object afterward. There is no object versioning, so an accidental overwrite cannot be recovered through built-in object history. A new-key workflow makes the current revision visible in the database and gives a cleanup process an exact stale key to remove.
The useful integration detail is the public discovery surface: one capability lookup returns the request schema, response schema, billing details, and a runnable example without requiring an API key. Every documented capability includes examples in 10 languages. That makes storage wiring a matter of reading the actual contract rather than learning another client library.
No magic layer.
Implement the fresh-key write in TypeScript
const baseUrl = process.env.INFRAI_BASE_URL;
async function putAvatar(bucket: string, key: string, body: Uint8Array) {
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`${baseUrl}/storage/object/put/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "image/webp",
"Idempotency-Key": `avatar:${key}`,
},
body,
},
);
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 3) {
throw new Error(`avatar upload failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("upload retry budget exhausted");
}
The method is explicit, the key comes from the environment, and a 429 produces bounded exponential backoff while honoring Retry-After. The client-created object key and idempotency key also keep a retried write attached to the same intended revision. A non-success response surfaces its body instead of being mistaken for a stored avatar.
Keep the bucket private. This storage is suitable for private user media, but it does not support public or public-read ACLs; public_url remains null. It is therefore not a permanent public avatar CDN or image-hosting origin. A protected application read can return the file with an appropriate Content-Disposition header, while access remains behind tenant authorization.
Test the restore race with two workers
The write sequence has two state changes, so model them. First, validate image type, tenant membership, and the client-generated revision ID. Write a new object key. Then insert its metadata as pending and update the tenant's current-avatar pointer inside a database transaction. After that commit, cleanup can delete the previous key and mark the new record ready.
A concrete race explains why this extra bookkeeping earns its keep. Suppose an agent changes an avatar while an operator selects yesterday's snapshot for restore. The upload writes one new key; the restore should copy its selected bytes into another new key. Each operation records its intended revision before attempting to move the current pointer. The database must serialize that pointer change with a lock or queue because this storage API has no If-Match conditional write. Cleanup examines only non-current rows for the same tenant and deletes their exact keys. The result is more code than a single overwrite, but the state can be inspected without guessing which file an app server happened to hold.
Short failures should leave boring states. If an object write completes before the database transaction, the object is an orphan candidate rather than another tenant's avatar. If the pointer transaction completes before old-object cleanup, the old key remains non-current until a retry removes it. A periodic cleanup task can reconcile pending rows and unreferenced keys, but tenant ID checks still belong on every transition. The useful design question here is not “can this be one call?” It is “can I explain every intermediate state during a support ticket?” This longer recovery path is the part I would exercise with two concurrent workers, a forced interruption before each commit boundary, and assertions that no row can ever point across tenants; the exact test numbers will vary, but the invariants should not.
For a selected snapshot, preserve immutable snapshot metadata in the database and restore into a new object key. Never count on an overwrite rollback. The same rule makes manual recovery less dramatic — the prior pointer and the replacement pointer are data, not an inference from a mutable filename.
Retry and recovery rules at scale
At larger scale, I would add a queue around pointer changes, measure object count and byte growth per tenant, and rehearse restore timing. I would also separate retention policy from user-facing deletion so a cleanup delay cannot silently redefine product behavior. I'm not sure what retention window is right without the product's deletion commitments and actual workload; those two inputs should settle it.
The catch is public delivery. This storage has no public ACL, so choose a storage and CDN setup designed for permanent public URLs when avatars must be directly hotlinked. It also has no object versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration tool. Regulated WORM records and workloads requiring built-in geographic replication need another provider or an external archive design.
Strict concurrent writes need database coordination or a queue because If-Match conditional writes are unavailable. Browser-direct upload also requires care: although the bucket model has cors_rules, there is no independently exposed CORS configuration route, so use an application-proxied upload or select storage where you can manage the required browser CORS policy. Lifecycle expiration has a one-day minimum, multipart fragments have no automatic cleanup rule, and metadata listing filters by prefix rather than server-side metadata search.
Check account limits before implementation. Trial credits cannot fund persistent writes. That makes a trial-only test insufficient evidence for a production upload path, regardless of how clean the code looks.
For most small US/EU support SaaS products, the decision rule remains plain: private object storage for avatar bytes, database rows for ownership and metadata, and a fresh key for each replacement or restore. Stick with database blobs when the payload is genuinely tiny and transactional coupling is worth the backup cost. Use local disk only when losing the file with an app instance is acceptable.
Top comments (0)