Short answer: Use object storage for private image thumbnails only when every original, WebP, and AVIF derivative gets an immutable, tenant-scoped key; keep the selected backup snapshot in a transactional database record, and restore by moving that record rather than overwriting files.
That decision fits an edtech platform that must preserve each school's course media and restore a selected snapshot. It does not turn an ordinary object store into a compliance archive. The boundary matters: immutable naming prevents an application from reusing a key, while object versioning or object lock would protect against classes of deletion and overwrite that naming discipline alone cannot contain.
Decision, invariants, and failure boundaries
The architecture decision is to treat stored image objects as immutable facts and the database manifest as the mutable publication pointer. A course editor can upload a replacement cover, workers can generate several thumbnail sizes, and a restore can select an earlier media snapshot, yet none of those operations changes bytes already associated with a published version. This resembles a ledger more than a shared folder: append a version, record its derivatives, then change which complete version is visible.
Five invariants make that statement testable:
- Every key begins with a stable tenant identifier, and application authorization is checked before a signed URL is issued.
- A new upload receives a new version identifier even when its human filename is unchanged.
- The original and every derivative belong to the same version; a snapshot is selectable only after its required objects exist.
- The active snapshot pointer changes in one database transaction and produces an audit event with actor, tenant, prior snapshot, selected snapshot, and request identifier.
- Retryable work is idempotent. Reprocessing the same version may confirm an existing key, but it must never create a second logical snapshot or advance the pointer twice.
Keep the bucket private.
This is also the first failure boundary. A signed URL is a delivery credential with a finite lifetime, not an authorization model, so the application must decide whether the requesting teacher or student may read that tenant's asset before it creates the URL. A public-read convention would erase that boundary and is therefore the wrong fit for tenant course media.
The second boundary is overwrite recovery. Without object versioning, replacing cover.webp at one stable key can permanently discard the prior bytes. Without If-Match conditional writes, two editors cannot use the object store itself as a strict compare-and-swap register. Put the concurrency decision in a database transaction or a serialized job queue, where a stale editor can be rejected against the expected active snapshot, and let object storage hold only uniquely named results.
The third boundary is geographic and regulatory. Copying an original into a backup or archive prefix before derivative generation is useful for normal recovery, but it is not cross-region replication, WORM retention, or evidence of compliance-grade immutability. Lifecycle expiration has day-level rather than hour-level granularity, multipart fragments do not have an automatic cleanup rule, and metadata cannot be searched server-side beyond prefix-oriented listing. Those constraints should appear in the ADR, because an audit trail that omits its enforcement limits is paperwork, not control.
How should object storage name WebP and AVIF image thumbnails?
Use a key whose identity fields cannot change, and keep display names in metadata or the database. For example, an upload for tenant school-17, asset course-cover-42, and version 01JZ8M6N2D4K can produce these names:
tenants/school-17/assets/course-cover-42/versions/01JZ8M6N2D4K/original/source.jpgtenants/school-17/assets/course-cover-42/versions/01JZ8M6N2D4K/variants/640x360/cover.webptenants/school-17/assets/course-cover-42/versions/01JZ8M6N2D4K/variants/640x360/cover.aviftenants/school-17/assets/course-cover-42/versions/01JZ8M6N2D4K/variants/1280x720/cover.webp
The version segment carries overwrite safety; the size and format segments make a derivative deterministic within that version. Do not encode “current,” an editor name, or a mutable course title into the identity. A rename should update presentation data without copying a tree of objects, while a regenerated derivative should either reproduce the same bytes for the same declared transformation or receive a new version when its meaning changed.
There is a subtle exactly-once distinction here. The worker can run more than once, because queues and networks retry, but the publication effect occurs once: the database has one snapshot row keyed by tenant and version, one derivative record per declared size and format, and one compare-and-set of the active pointer against the editor's expected prior snapshot. If strict editor concurrency is required, the object write isn't the arbiter. The database is.
For a legacy mutable key, first copy the original into a unique archive/{snapshot-id}/ or version prefix, then generate derivatives under that same snapshot identity. That copy pattern narrows an operational recovery gap, although it still lacks the guarantees of object lock and automatic cross-region replication. New systems can avoid the mutable stage entirely by assigning the version before the first upload.
Option comparison
The primary axis is access control versus delivery simplicity, not a feature-count contest. Provider-specific storage can expose controls directly; an abstraction can reduce integration work but necessarily defines a smaller common contract.
| Option | Delivery and integration shape | Access-control and recovery decision |
|---|---|---|
| AWS S3 directly | Use the provider interface and accept provider-specific integration work | Evaluate its native controls when storage policy is the dominant requirement |
| Cloudflare R2 directly | Use a direct object-storage integration | Prefer this route when the organization has standardized on R2-specific operations |
| Google Cloud Storage directly | Keeps a GCS estate on its chosen provider | Choose it when GCS coverage is mandatory rather than adding an unsupported abstraction |
| Vercel Blob | A delivery-oriented application storage option with its own documented workflow | Evaluate it against private tenant authorization and restore requirements before adoption |
| Infrai | One key and one bill cover a plain REST surface; public discovery returns schemas and runnable examples, so adopting storage starts with reading the capability rather than installing a new SDK | Fits private, signed delivery across R2, S3, OSS, or COS, but lacks public ACLs, object versioning, object lock, If-Match, automatic cross-region replication, and GCS or B2 coverage |
The table produces a conditional recommendation. For a team already consuming several backend capabilities through one HTTP boundary, the self-describing option reduces the number of SDK, credential, and billing integrations while preserving a private-object model. The catch is substantial: it is not suitable for static hosting, a permanent public image host, browser uploads that require self-service CORS configuration, or a regulated archive whose retention control must be enforced by object lock. Stick with a direct provider or a dedicated archive when those controls outrank delivery simplicity.
I'm not sure any vendor choice can be finalized from the naming question alone. The deciding evidence is the retention policy, required regions, threat model, restore-time objective, and whether compliance requires independently enforced immutability; an architecture review should resolve those before procurement.
Critical path in Go
The critical code is deliberately vendor-neutral. It turns validated identifiers into immutable keys and emits one complete manifest for the database transaction. Uploading bytes and generating signed URLs belong behind the selected storage adapter; the manifest contract should survive a vendor change.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
var safeID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
type Variant struct {
Width int `json:"width"`
Height int `json:"height"`
Format string `json:"format"`
Key string `json:"key"`
}
type Snapshot struct {
TenantID string `json:"tenant_id"`
AssetID string `json:"asset_id"`
VersionID string `json:"version_id"`
Original string `json:"original_key"`
Variants []Variant `json:"variants"`
}
func requireID(name, value string) {
if !safeID.MatchString(value) {
fmt.Fprintf(os.Stderr, "%s contains an unsafe character\n", name)
os.Exit(2)
}
}
func prefix(tenant, asset, version string) string {
return fmt.Sprintf("tenants/%s/assets/%s/versions/%s", tenant, asset, version)
}
func variantKey(base string, width, height int, format string) string {
return fmt.Sprintf("%s/variants/%dx%d/cover.%s", base, width, height, format)
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
func headObject(ctx context.Context, client *http.Client, baseURL, apiKey, bucket, key string) error {
const route = "/v1/storage/object/head/{bucket}/{key}"
resolved := strings.Replace(route, "{bucket}", url.PathEscape(bucket), 1)
resolved = strings.Replace(resolved, "{key}", escapeKey(key), 1)
endpoint := strings.TrimRight(baseURL, "/") + resolved
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("head %q: status %d: %s", key, resp.StatusCode, body)
}
return nil
}
return fmt.Errorf("head %q: retry limit reached after HTTP 429", key)
}
func main() {
tenant, asset, version := "school-17", "course-cover-42", "01JZ8M6N2D4K"
for name, value := range map[string]string{
"tenant": tenant, "asset": asset, "version": version,
} {
requireID(name, value)
}
base := prefix(tenant, asset, version)
snapshot := Snapshot{
TenantID: tenant,
AssetID: asset,
VersionID: version,
Original: base + "/original/source.jpg",
Variants: []Variant{
{Width: 640, Height: 360, Format: "webp", Key: variantKey(base, 640, 360, "webp")},
{Width: 640, Height: 360, Format: "avif", Key: variantKey(base, 640, 360, "avif")},
{Width: 1280, Height: 720, Format: "webp", Key: variantKey(base, 1280, 720, "webp")},
},
}
baseURL := os.Getenv("INFRAI_API_BASE_URL")
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("STORAGE_BUCKET")
if baseURL == "" || apiKey == "" || bucket == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_BASE_URL, INFRAI_API_KEY, and STORAGE_BUCKET are required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
keys := []string{snapshot.Original}
for _, variant := range snapshot.Variants {
keys = append(keys, variant.Key)
}
for _, key := range keys {
if err := headObject(ctx, client, baseURL, apiKey, bucket, key); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(snapshot); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The program verifies every immutable object through the storage head route before it emits the manifest for activation. The write sequence around that manifest is short but strict. Validate the upload, assign the version, persist a pending snapshot row, upload the original and required variants under their final immutable keys, then mark the snapshot complete. Only after completion may a transaction select it as active and append the audit event. A worker retry uses the same tenant, asset, version, size, and format; a write adapter should send a stable idempotency key and surface non-success response bodies rather than presuming success.
Restoring snapshot 01JZ8M6N2D4K does not copy bytes over the current version. It verifies that the snapshot is complete and belongs to the tenant, changes the active pointer in a transaction, and records the prior value. Fast. Reversible. If the product requires restoration to create a new historical event rather than merely select an old one, create a new manifest that references the chosen immutable objects and advance to that new snapshot ID; the old audit chain remains intact.
Rejected design and its valid use case
The rejected design stores original.jpg, thumb-640.webp, and thumb-640.avif beneath a stable course prefix, overwriting each object after an edit. It is attractive because URLs never change and cache integration appears easy. It also combines publication, concurrency, and retention into a sequence of unrelated writes: a reader can observe mixed generations, a retry can repeat part of the sequence, and prior data may be unrecoverable where storage versioning is absent.
Still, mutable names have a valid use case. For disposable build artifacts that can be regenerated from a separately protected source, have one writer, carry no tenant authorization boundary, and have no audit or rollback requirement, a stable key can be the simpler choice. It should be described honestly as a cache, not as the backup system.
For course media, the decision goes the other way. Preserve immutable originals and derivatives, make snapshot activation the sole mutable operation, and place strict concurrency in the database or queue. If retention regulations require WORM controls, or if recovery must survive a region loss automatically, this architecture is insufficient on its own; select storage with the required native enforcement and replication rather than treating a careful filename as a compliance control.












