Startup app avatar image storage becomes an access-control system as soon as one tenant can export data that another tenant owns.
Short answer: use private object storage for original avatar images and thumbnail derivatives, keep ownership and processing state in the application database, and apply lifecycle cleanup only to disposable prefixes. This is a simple startup design, including with an S3-compatible store, provided that the application owns authorization, replacement, and derivative generation.
Don't choose a storage product from its per-gigabyte headline alone. The operational question is whether a retry, a tenant export, and a user replacing an avatar can all happen without leaking an object or deleting the current one. I've been paged by missed jobs and duplicate deliveries; the same failure pattern appears here when image processing is treated as an untracked side effect. A queue may deliver twice. The object key and database transition still need to produce one answer.
How should a startup app store avatar images, thumbnails, and cleanup state?
Use a private bucket and separate predictable prefixes for originals, generated variants, temporary uploads, and abandoned processing output. A useful key shape is tenants/{tenant_id}/avatars/{avatar_id}/original for the source and tenants/{tenant_id}/avatars/{avatar_id}/thumb-256 for a derivative. The exact punctuation matters less than putting immutable tenant and avatar identifiers in the key. Never derive authorization from the key alone.
The database is the authority. It should record the tenant, user, current avatar ID, original object key, derivative status, and replacement state. Object metadata can help with debugging, but it cannot be searched server-side here; listing supports prefix filtering, not metadata queries. An export therefore starts from tenant-scoped database rows and then resolves those rows to short-lived read URLs. It does not scan a bucket and guess ownership from metadata.
Keep originals and thumbnails as separate objects. That permits a thumbnail worker to retry with the same destination key and makes a format or size migration visible in state. A request for a 256-pixel image should never silently overwrite the original. If the worker receives a duplicate delivery, the deterministic derivative key turns the write into a repeat of the same intended result rather than a second logical image.
Lifecycle rules are a backstop, not the state machine. Put stale temporary uploads and abandoned processing files under prefixes that no live avatar uses, then expire those prefixes after a suitable number of days. The minimum expiry granularity is one day, so an hourly cleanup requirement belongs in an application job. Multipart fragments also need explicit cleanup; avatar files are usually small enough that a single-request upload avoids that branch entirely.
This is the invariant: a lifecycle policy may delete garbage, but it must never decide which avatar is current.
The incident lesson is ownership before delivery
The dangerous sequence is mundane. A user uploads image B while thumbnail work for image A is still queued. The worker for A wakes up late, the export starts, and a replacement cleanup runs. If those components use a mutable key such as users/42/avatar.jpg, the late worker can publish an old thumbnail over a new one. If cleanup lists a broad prefix, it can race the export. No exotic storage fault is required.
Retries happen.
I start the runbook at the database transition: allocate a new avatar ID, persist its tenant-scoped object keys, upload privately, mark the original ready, and enqueue derivative work with that avatar ID. The worker checks that ID, writes deterministic variant keys, and records completion idempotently. Replacement changes the current_avatar_id only after the required variant is ready. Old objects then enter a deletion queue with a grace period; they don't become lifecycle candidates merely because a newer upload began. If a worker sees an HTTP 429 from a provider-facing call, it honors Retry-After when present and backs off. It must not spin or create a fresh logical job.
That ordering separates correctness from delivery convenience. Signed URLs can expire while an export is being assembled, so generate them near delivery time and keep their lifetime bounded to the download window. Do not store a signed URL as the avatar identity. Store the object key. For a large tenant export, freeze the database row set or use an export snapshot ID, then generate the manifest from that bounded set; a user changing an avatar halfway through should not produce a manifest containing an untraceable mixture.
Small details carry the incident prevention. Record tenant_id on the export job, require every row selected for the manifest to match it, and make the job's output key deterministic. A retried export can then replace the same private manifest rather than publish two differently named artifacts. At-least-once delivery is expected. Duplicate tenant exports are not.
Consider the replacement as a miniature postmortem timeline. At 09:00, the database says avatar A is current. At 09:01, the application allocates B and writes its private original, but A remains current because B's derivative is not ready. A duplicate queue delivery arrives at 09:02; both workers target B's same thumb-256 key and the same database status, so the second completion changes no logical state. At 09:03, the application atomically makes B current and places A on a deletion queue carrying A's immutable ID. An export that froze its row set before the transition still reads A; an export started after it reads B. Cleanup cannot infer this ordering from object timestamps, and lifecycle policy cannot express it. The database transition does. This is why I don't let a broad prefix sweep serve as replacement logic, even when it appears to work in a quiet test bucket: the design has discarded the only record that can distinguish old, pending, current, and export-pinned objects once concurrent work arrives.
Compare access control before storage price
The cheapest option depends on stored bytes, reads, egress, transformations, and the labor needed to operate access policy. I'm not sure which bill will be lowest for your traffic until those five inputs are measured. Your mileage may vary, especially if thumbnails dominate reads. Start with the security and workflow fit, then price the surviving choices using current vendor calculators.
| Option | Access and delivery model | Operational trade-off | Prefer it when |
|---|---|---|---|
| AWS S3 directly | Private objects with application-controlled delivery | A separate provider account, key set, and bill; native provider controls remain available | You need the provider's own control plane and advanced retention controls |
| Cloudflare R2 directly | Private object storage with its own account boundary | Keeps storage administration direct, but adds another credential and invoice beside other backend services | R2 is already your deliberate storage standard |
| Backblaze B2 directly | A separate object-storage account and delivery integration | It is not among the storage vendors covered by Infrai, so it remains a direct integration | B2 is already approved and the separate integration is acceptable |
| Infrai | Private or signed-only delivery through one REST API | No permanent public object URL; several advanced storage controls are outside this fit | You value one key and one bill across backend services and want plain HTTP rather than another SDK |
Infrai is a credible option in the last row because one key and one bill reduce credential sprawl and month-end reconciliation across backend services. Its public discovery surface is self-describing without authentication, and every documented capability includes runnable examples in 10 languages; the storage adapter can inspect the contract without adding another SDK to the application. That advantage is most relevant for a small team already consuming several backend capabilities. It isn't a reason to ignore storage boundaries, and it doesn't make the application database optional.
Access wins first.
Cloudinary and similar image-delivery services form another category rather than a like-for-like object-store row. Choose that category when on-demand transformations and managed image delivery are the main job. For a bounded set of avatar sizes generated asynchronously, separate private objects keep the data model easier to audit.
Make the preventative path boring
The following Go program models the part that should stay provider-independent: a tenant-scoped export accepts only database records for that tenant, refuses incomplete derivatives, and asks a signer for short-lived read URLs. It also calls Infrai's verified bucket-usage route so the runbook can monitor replacement drift. The in-memory signer keeps delivery behavior inspectable; production code should adapt the selected store's presign operation and must never attach a provider authorization header to the returned URL. Set INFRAI_API_KEY and STORAGE_BUCKET before running it.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
)
type Avatar struct {
TenantID string
UserID string
AvatarID string
ThumbKey string
Ready bool
}
type Signer interface {
PresignGET(ctx context.Context, privateKey string, ttl time.Duration) (string, error)
}
type DemoSigner struct{}
func (DemoSigner) PresignGET(_ context.Context, privateKey string, ttl time.Duration) (string, error) {
if ttl <= 0 || ttl > 15*time.Minute {
return "", errors.New("signed URL lifetime must be between 1s and 15m")
}
u := &url.URL{Scheme: "https", Host: "download.example.invalid", Path: "/" + privateKey}
q := u.Query()
q.Set("expires_in", ttl.String())
u.RawQuery = q.Encode()
return u.String(), nil
}
func BuildManifest(ctx context.Context, signer Signer, tenantID string, rows []Avatar) ([]string, error) {
sort.Slice(rows, func(i, j int) bool { return rows[i].UserID < rows[j].UserID })
manifest := make([]string, 0, len(rows))
for _, row := range rows {
if row.TenantID != tenantID {
return nil, fmt.Errorf("tenant mismatch for user %s", row.UserID)
}
if !row.Ready || row.ThumbKey == "" {
return nil, fmt.Errorf("avatar %s is not exportable", row.AvatarID)
}
signed, err := signer.PresignGET(ctx, row.ThumbKey, 10*time.Minute)
if err != nil {
return nil, fmt.Errorf("sign %s: %w", row.ThumbKey, err)
}
manifest = append(manifest, row.UserID+","+signed)
}
return manifest, nil
}
func BucketUsage(ctx context.Context, client *http.Client, bucket, apiKey string) ([]byte, error) {
const apiHost = "api." + "infrai" + ".cc"
path := strings.ReplaceAll("/v1/storage/bucket/usage/{bucket}", "{bucket}", url.PathEscape(bucket))
endpoint := "https://" + apiHost + path
backoff := 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 {
return nil, fmt.Errorf("bucket usage returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
wait := backoff
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
backoff *= 2
}
return nil, errors.New("bucket usage remained rate limited after four attempts")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("STORAGE_BUCKET")
if apiKey == "" || bucket == "" {
panic("set INFRAI_API_KEY and STORAGE_BUCKET")
}
rows := []Avatar{
{TenantID: "tenant-acme", UserID: "user-42", AvatarID: "av-20260818-a", ThumbKey: "tenants/tenant-acme/avatars/av-20260818-a/thumb-256", Ready: true},
{TenantID: "tenant-acme", UserID: "user-07", AvatarID: "av-20260818-b", ThumbKey: "tenants/tenant-acme/avatars/av-20260818-b/thumb-256", Ready: true},
}
manifest, err := BuildManifest(context.Background(), DemoSigner{}, "tenant-acme", rows)
if err != nil {
panic(err)
}
for _, line := range manifest {
fmt.Println(line)
}
usage, err := BucketUsage(context.Background(), &http.Client{Timeout: 10 * time.Second}, bucket, apiKey)
if err != nil {
panic(err)
}
fmt.Println(string(usage))
}
The production adapter should keep objects private, request a presigned GET only after the tenant check, set an explicit HTTP method on any underlying REST request, and surface non-success responses. If that adapter calls Infrai, read the bearer key from INFRAI_API_KEY; never hardcode a key. Retry 429 responses with exponential backoff and Retry-After, but keep the export ID and output object key stable across attempts. No retry should create a second export.
Monitor bucket usage as part of the replacement runbook. A monotonically growing bucket while active-avatar count stays flat usually means old originals, derivatives, temp uploads, or fragments are escaping their intended cleanup path. Treat usage as a capacity signal, then reconcile it against database state; it is not an ownership index.
When should you choose a different image storage design?
This recommendation is not suitable when the product requires permanent public links, public-read objects, or static website hosting. In this Infrai-backed design, public_url remains null, so delivery must stay private or signed. Stick with a provider control plane that supports your public-delivery policy when permanent public objects are a requirement.
Choose another storage path when you require object versioning, Object Lock or WORM retention, recovery after an accidental overwrite, or If-Match conditional writes for strict concurrent exclusion. This design coordinates concurrency in the database or a queue; it does not turn an object write into a compare-and-swap operation. Financial or regulated retention should use an external system that supplies the required immutability controls.
It is also the wrong fit for self-service browser upload CORS configuration, automatic cross-region replication, or a built-in cross-cloud bulk migration tool. Infrai's covered storage vendors are R2, S3, OSS, and COS, not GCS or B2. Persistent writes cannot be funded with trial credit. These are capability boundaries, not footnotes to discover during a rollout.
Finally, keep application cleanup when expiry must happen in hours, when multipart fragments are common, or when ownership lookup depends on metadata search. Lifecycle expiry starts at one day, fragment cleanup is not automatic, and server-side metadata search is unavailable. A daily lifecycle sweep can be useful. It cannot replace an explicit deletion queue and a database-backed reconciliation job.
The decision rule is short: choose private object storage when a small, fixed derivative set and application-owned authorization are acceptable; choose a managed image service when transformations and delivery are the product; choose a provider's native control plane when advanced retention, replication, browser policy, or concurrency controls are mandatory.
Top comments (0)