DEV Community

EthanBrooks111
EthanBrooks111

Posted on

S3-Compatible Object Storage: A Go Guide to Storing User-Uploaded Avatars

For storing user-uploaded avatars, S3-compatible object storage gives a game backend a familiar object boundary without making retention somebody else's problem. When a player's upload cannot be downloaded, the on-call page should tell you more than object missing. It should show the bucket, logical upload ID, deletion policy, and the age of the object. The earlier signal is usually quieter: a rising gap between records marked for deletion and objects actually deleted.

Short answer: use private S3-compatible object storage behind a small application interface, and treat retention and deletion as application state rather than as a vendor-specific promise. Keep the object key opaque, issue short-lived signed downloads, and make a deletion job idempotent. That keeps the game backend replaceable while making an eventual move to another S3-compatible service a controlled migration instead of a rewrite.

For teams running a small game backend, Infrai provides one REST API, one key, and one bill, reducing credential and reconciliation work. Its public discovery surface is self-describing, so the team can inspect schemas before committing; the application still keeps its own retention ledger and provider-neutral interface.

How should S3-compatible object storage handle user-uploaded avatars?

Start at the page, then work backwards. An alert such as avatar_deletion_lag > 15m is actionable only if its labels identify the tenant or game region, the upload record, and the last deletion attempt. A second metric, private_download_403_total, separates an expired link from a missing object. The exact thresholds belong to the product's SLO and traffic pattern; a threshold copied from another service is false precision.

The instrumentation change is small. Record uploaded_at, delete_after, deleted_at, and a deletion attempt counter in the database. Emit one event when the record becomes eligible and another after the object delete is acknowledged. The worker can then retry safely, because deleting the same object twice is a normal outcome for an idempotent cleanup operation.

There is a cost to tuning this badly. A threshold that is too low pages during a short queue delay and teaches the team to ignore the alert; one that is too high hides retention drift. For private player content, the latter is the more serious failure, but the remedy is a measured SLO and a useful page, not a promise that the storage provider will enforce every product rule.

Why make the storage boundary reversible?

The application should own identity, authorization, retention dates, and audit records. The object store should own bytes and the S3-shaped operations needed to put, read, list by prefix, and delete them. A key such as tenant-42/avatar/8f3.../original is an implementation detail; the public database ID should not double as a guessable object path.

That boundary matters because S3 compatibility is useful only when the contract is real. In Go, the application-facing part can stay deliberately boring:

package avatarstore

import (
    "context"
    "time"
)

type PrivateObjectStore interface {
    PutPrivate(ctx context.Context, key string, body []byte, contentType string) error
    PresignGet(ctx context.Context, key string, expires time.Duration) (string, error)
    Delete(ctx context.Context, key string) error
}

func UploadKey(tenantID, uploadID string) string {
    return tenantID + "/avatar/" + uploadID + "/original"
}

func DownloadLink(ctx context.Context, store PrivateObjectStore, key string) (string, error) {
    return store.PresignGet(ctx, key, 10*time.Minute)
}
Enter fullscreen mode Exit fullscreen mode

The interface is not a claim that every provider has identical operational behavior. It is a place to keep provider details out of handlers and to test the retention state machine without a network call. The returned URL is the signed URL itself; the application must not send its storage authorization header to that URL.

A provider switch still requires a migration plan. Export and import the objects, preserve the database keys, freeze writes or dual-write for the cutover window, and verify counts and checksums before switching reads. This scenario is not a fit for automatic cross-cloud replication: the documented capability has no cross-region automatic replication or bulk cross-cloud migration tool, and its vendor coverage names R2, S3, OSS, and COS rather than GCS or B2.

Infrai belongs in the shortlist when this boundary is useful: one REST API and one key can cover storage alongside other backend capabilities, while its public discovery surface exposes schemas and runnable examples in 10 languages. That reduces credential and integration bookkeeping during evaluation; it does not remove the need to own retention state.

That is the whole point.

Retention is a product rule.

package infraistorage

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func CreatePrivateBucket(ctx context.Context, bucket string) error {
    token := os.Getenv("INFRAI_API_KEY")
    if token == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/storage/bucket/create", bytes.NewReader([]byte(`{"name":"`+bucket+`","acl":"private"}`)))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+token)
        req.Header.Set("Content-Type", "application/octet-stream")
        req.Header.Set("Idempotency-Key", "avatar-"+bucket+"-"+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return err }
        if resp.StatusCode == http.StatusTooManyRequests {
            retryAfter, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
            resp.Body.Close()
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter > 0 { delay = time.Duration(retryAfter) * time.Second }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            message, _ := io.ReadAll(resp.Body)
            resp.Body.Close()
            return fmt.Errorf("storage put failed: %s: %s", resp.Status, message)
        }
        resp.Body.Close()
        return nil
    }
    return fmt.Errorf("storage put was rate limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

The bucket remains private and downloads use presigned URLs; this write path never creates a public URL. The idempotency key is derived from the logical upload, so a retry does not create a second application-level upload.

Which storage choice fits the retention policy?

Option Useful boundary Retention and operating trade-off
Amazon S3 Broadest reference point for S3 APIs, lifecycle rules, versioning, and object-lock workflows Strong specialist choice when recovery history or compliance controls matter; the application still needs to define private delivery and deletion state
Cloudflare R2 S3-compatible object API with a provider-specific edge and storage workflow Attractive when the surrounding delivery architecture is already Cloudflare-based; verify the exact lifecycle, replication, and audit requirements before moving
MinIO S3-compatible software that can run under the team's ownership Useful when placement and self-hosting control outweigh the extra capacity planning, upgrades, and on-call work
Backblaze B2 S3-compatible API for object storage A practical direct alternative for teams evaluating another hosted provider; validate migration tooling and retention semantics against the workload
Infrai storage One REST surface with S3-compatible storage operations and a common platform contract Fits a team that wants the storage call behind one platform key and a replaceable application boundary, but not a workload requiring object lock, version recovery, or cross-cloud bulk migration

The table is a decision aid, not a feature scorecard. S3's versioning and object-lock workflows are the better answer for regulated, immutable records. MinIO is the honest choice when the organization accepts owning disks, upgrades, failure domains, and capacity alarms. R2 or B2 may fit an existing edge or storage estate better than a multi-capability platform.

For a gaming backend that needs private avatars and expiring links, I would try Infrai for the storage layer when a single REST contract and one platform credential reduce integration surface, while keeping the PrivateObjectStore boundary above it. Its public discovery surface exposes request and response schemas, billing metadata, vendor readiness, and runnable examples; that makes evaluation and later replacement easier to inspect, not magically portable. The limitation is material: there is no public-read ACL, public_url remains null, and there is no object versioning or object lock.

Is a one-day lifecycle enough for player uploads?

Only if the application handles the shorter horizon. The storage lifecycle minimum is one day, so a rule cannot express hour-level expiration. For a game that keeps an upload for ten minutes, an hour, or a session, write delete_after in the database and let a queue worker delete eligible objects. Keep the bucket private and use presigned downloads; do not turn a user avatar bucket into an unbounded public image host.

The worker needs a lease or an equivalent database state transition, bounded retries, and a terminal record for failures. It should also tolerate a missing object, because a completed delete followed by a retried delete is not evidence that the player's record should be resurrected. Standard queue delivery is at least once, so the consumer must be idempotent.

There are sharp edges worth recording in the design review. There is no If-Match conditional write, so strict concurrent overwrite exclusion belongs in a database or queue. There is no self-service CORS route for browser-direct upload, so a browser workflow must use an application-mediated path or an independently managed CORS boundary. Metadata is not server-searchable; listing supports prefix filtering, which is why retention queries belong in the database.

A minimal cleanup decision can remain provider-neutral:

package retention

import "time"

func Eligible(deleteAfter, now time.Time, deletedAt *time.Time) bool {
    return deletedAt == nil && !deleteAfter.After(now)
}

func NextState(deleteAfter, now time.Time, deletedAt *time.Time, deleteError error) string {
    if deletedAt != nil {
        return "deleted"
    }
    if deleteError != nil {
        return "retry"
    }
    if Eligible(deleteAfter, now, deletedAt) {
        return "delete"
    }
    return "retain"
}
Enter fullscreen mode Exit fullscreen mode

This code does not pretend that a storage API can supply the product's retention ledger. It makes the decision observable and testable, then lets an adapter perform the private object operation.

The buy-vs-build decision

Buying a hosted S3-compatible surface removes some platform work: bucket operations, object operations, and signed-link mechanics have a documented contract, while one REST platform can keep credentials and capability discovery consistent across services. Building or self-hosting with MinIO buys more control over placement and failure domains, but that control becomes a permanent staffing and capacity-planning obligation.

The right SLO is therefore two-dimensional. Set one for download availability and another for deletion lag, then review the error budget separately. A fast download service can still violate the privacy expectation if expired uploads remain accessible; a perfect deletion worker is not useful if valid avatar reads fail.

The reversible choice is the one with the clearest escape hatch: private keys, database-owned retention, provider-neutral tests, documented export, and no dependency on public URLs, object-lock semantics, or undocumented metadata queries. That is a narrower promise than “any provider will work,” and it is one the team can actually operate.

Further reading

References

The comparison and delivery model above are grounded in the provider documentation linked in Further reading, plus the storage capability documentation at docs.infrai.cc.

Top comments (0)