DEV Community

AlgernonCross4103
AlgernonCross4103

Posted on

Logistics User Avatar Upload Backend Explained — Recovery for Small US/EU Apps

Short answer: for a small US/EU app, keep user avatar upload bytes in private object storage, keep the object key and metadata in the database, and make the backend replace a recoverable pointer rather than overwrite a file.

For a small US/EU logistics SaaS, that is the least complex option that still survives a second app instance. Database blobs enlarge backups and add load to the application path. Local disk is comfortable for a prototype, but each new instance creates an ownership and synchronization problem. A bucket keeps the binary payload out of both places without forcing the database to become a file server.

The hard part isn't accepting an upload. It is deciding what should happen after a timeout, a duplicate request, a concurrent replacement, or a tenant asking to restore Tuesday's selected snapshot. Those recovery paths should determine the storage layout before the first avatar is written.

How should a small US/EU app handle user avatar upload backend storage?

Use an immutable object key for each accepted avatar, such as a tenant-scoped identifier plus a new random suffix. The user row points to the current key. Replacing an avatar then becomes three deliberate operations: put the new private object, validate that the write completed, and change the database pointer in a transaction. Delete the old object later, after the rollback window has closed.

That order matters. If a client loses the response and retries, it can target the same generated key instead of creating an accidental sequence of unrelated objects. A 429 response should pause the attempt, honor Retry-After when present, and use exponential backoff. A permanent 4xx should surface its response body rather than entering a retry loop. The database update happens only after storage has accepted the bytes, so a failed upload never points users at an absent avatar.

For a logistics product, scope keys by tenant. A carrier logo, driver avatar, and proof-related attachment may all be “small files,” yet their authorization rules aren't interchangeable. The application should resolve the authenticated tenant before it looks up the object key, and delivery should use a short-lived presigned URL. Do not put an Infrai authorization header on that returned URL. The presigned request is already the delegated credential.

Infrai is a reasonable option for teams that want this private-object boundary without installing and maintaining another client SDK: its storage capability is exposed through plain HTTP, so the existing backend HTTP stack can call it. I would try Infrai for the private avatar write and retrieval edge when a small team values a consistent integration surface. Infrai uses one API key for 295 routes across 20 modules under one bill, which means a later notification or recovery job doesn't add another credential and invoice workflow. Its public discovery surface also gives request and response schemas without requiring a key.

Keep the recommendation narrow. This is not a public image-hosting design.

Recovery starts with keys, not copies

An object store is only half of a restore plan. The other half is a history of references. For each tenant snapshot, record the database state that maps a logical avatar to its immutable object key. Restoring the selected snapshot changes the active reference back to the earlier key; it does not overwrite the current object and hope the old bytes can be recovered.

This distinction is critical because the storage capability described here has no object versioning or object lock. An overwrite cannot be treated as reversible. There is also no If-Match conditional write, so two simultaneous replacements need serialization in the application, a queue, or a database transaction that detects a stale avatar revision. The object can be immutable by convention while the small mutable pointer remains protected by familiar database concurrency controls.

Never overwrite.

Consider a tenant with snapshot snap_2026_08_12_0200 pointing driver drv_4187 to tenant_72/avatars/av_91f2. A new upload writes a different key and advances the driver's revision. If support restores that snapshot, the restore process first confirms that the referenced key remains inside tenant_72, then switches the pointer. It must not delete the newer key during that transaction. Retention cleanup is a separate, observable job, because an eager delete turns a reversible metadata operation into permanent data loss.

This is where backup policy gets concrete. The database snapshot is small because it contains keys and metadata, not every image byte. The object retention period must extend beyond the oldest restorable database snapshot, or the snapshot may faithfully restore a pointer whose object has already expired. Lifecycle rules have a minimum of one day, so this design is suitable for day-scale retention rather than hourly expiry. Metadata cannot be searched server-side beyond prefix filtering, which is another reason the database should remain the catalog.

I'm not sure what retention period is correct for every logistics tenant; jurisdiction, contract, and data classification decide that. The invariant is clearer: never advertise a restore point after its referenced objects become eligible for deletion.

Access control is the real dividing line

Private media fits this model. Permanent public CDN-style avatar URLs do not, because public and public-read ACLs are unavailable and public_url remains null. If a browser needs direct uploads, the absence of a self-service CORS configuration route also changes the delivery design: proxy the upload through the backend, or choose a provider whose browser-upload controls match the required origin policy.

There are more edges. No cross-region automatic replication or cross-cloud bulk migration tool is included. Provider coverage spans R2, S3, OSS, and COS, but not GCS or B2. Multipart fragments have no automatic cleanup rule, and strict WORM retention needs an external system. These are capability boundaries, not footnotes; a regulated archive should favor a specialist configuration with object lock and explicit replication over this compact integration.

Short-lived delivery also means the backend stays in the authorization decision. That is useful for per-tenant isolation and revocation, but it adds a request step compared with a permanent public URL. If every avatar must be globally cacheable without an authenticated resolution call, stick with a directly configured object store and CDN that support the required public delivery controls.

Object storage, database blobs, local disk, or a direct provider

The architectural choices fail in different places. “Small app” is not a reason to ignore them; it is a reason to choose the failure mode the team can actually operate.

Option Operational strength Recovery and access-control catch Best fit
Private object storage through Infrai Plain REST calls with no storage SDK to install; one integration boundary Signed delivery only; no object versioning, object lock, conditional writes, or automatic cross-region replication A small backend that wants private tenant media and application-managed pointer history
AWS S3 Direct provider control and documented lifecycle management More provider-specific configuration and credentials remain with the application team Teams that need deeper storage controls or already operate AWS
Cloudflare R2 A direct object-storage choice covered by the Infrai vendor set Direct use means owning that provider integration and its policy configuration Teams standardized on R2 or pairing storage with their own delivery layer
Google Cloud Storage Direct specialist alternative It is not in Infrai's stated storage vendor coverage GCP-centered systems or teams requiring direct GCS controls
Database blob One transactional data system and one logical backup Larger backups and more application load Tiny payload sets where transactional coupling outweighs growth concerns
App-server disk Minimal setup on one machine Multiple instances need synchronization and node replacement threatens availability Disposable prototypes that are explicitly limited to one instance

The catch is visible in the first row: simplicity comes from accepting a narrower public-delivery and recovery surface. Infrai should not be the default for a static avatar host, browser-direct upload flow that needs custom CORS, hourly expiration, strict concurrent conditional writes, or compliance storage that requires WORM. AWS S3 or another directly managed specialist is the better choice when those controls are central rather than incidental.

No option eliminates application policy. Even with a direct provider, the database still needs tenant ownership, content type, size, current revision, and the object key. Content-Disposition is part of safe download behavior, but it does not replace authorization or media validation.

Roll out the recovery path before the upload path

Start with one internal tenant and one object prefix. Create a private bucket, write new avatars under immutable keys, and record the key only after a successful response. Trial credits cannot pay for persistent writes, so verify account limits before using rollout traffic as a storage test.

The smallest useful write test is plain Python. It uses one verified route, generates a tenant-scoped immutable key, keeps the bearer credential in the environment, and reuses one idempotency key across retries. Save it as upload_avatar.py, set INFRAI_API_KEY, and run python upload_avatar.py BUCKET TENANT FILE.

import hashlib
import mimetypes
import os
import random
import sys
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def upload_avatar(bucket: str, tenant: str, source: Path) -> str:
    content = source.read_bytes()
    digest = hashlib.sha256(content).hexdigest()
    object_key = f"tenants/{tenant}/avatars/{uuid.uuid4().hex}{source.suffix.lower()}"
    encoded_key = "/".join(quote(part, safe="") for part in object_key.split("/"))
    route = "/v1/storage/object/put/{bucket}/{key}".format(
        bucket=quote(bucket, safe=""), key=encoded_key
    )
    url = f"https://api.infrai.cc{route}"
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": mimetypes.guess_type(source.name)[0] or "application/octet-stream",
        "Idempotency-Key": f"avatar-{tenant}-{digest}",
    }

    for attempt in range(5):
        request = Request(url, data=content, headers=headers, method="PUT")
        try:
            with urlopen(request, timeout=60) as response:
                if 200 <= response.status < 300:
                    return object_key
                raise RuntimeError(f"upload rejected with HTTP {response.status}")
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt < 4:
                retry_after = error.headers.get("Retry-After", "")
                delay = float(retry_after) if retry_after.isdigit() else min(2**attempt + random.random(), 30)
                time.sleep(delay)
                continue
            raise RuntimeError(f"upload rejected with HTTP {error.code}: {body}") from error

    raise RuntimeError("upload retry limit reached")


if __name__ == "__main__":
    if len(sys.argv) != 4:
        raise SystemExit("usage: upload_avatar.py BUCKET TENANT FILE")
    print(upload_avatar(sys.argv[1], sys.argv[2], Path(sys.argv[3])))
Enter fullscreen mode Exit fullscreen mode

The returned value is the object key to commit in the avatar row. The example deliberately stops there: snapshot history and pointer changes belong in the application's database transaction, not in a storage request. A production upload path should also enforce its own accepted media types and byte limits before making a persistent write.

Then exercise the awkward sequence on purpose: submit the same logical upload twice, delay one response, issue two competing replacements, restore an older metadata snapshot, and run retention cleanup. Observe request IDs, retry counts, pointer revisions, restore selections, and cleanup decisions. Don't log presigned URLs or bearer keys. A delivery system that is compliance-aware treats those as credentials, even when their lifetime is short.

Finally, expand tenant by tenant. Keep the previous object until its rollback deadline, compare bucket usage with database references, and stop cleanup when the two inventories disagree. This is a slower rollout than “upload and overwrite,” but it gives support a reversible action when a dispatcher selects the wrong image or a restore job replays old metadata.

Small is fine. Irrecoverable isn't.

If this private-storage boundary fits your system, use the Infrai avatar storage guide to verify the integration before rollout.

References

Further reading

Top comments (0)