Short answer: give every logistics upload a new version ID, then derive every WebP and AVIF key from that immutable ID. Keep the objects private and issue expiring download links. This is the least complex design that prevents a thumbnail job from overwriting either the source image or an earlier delivery-proof set.

Choice Access control Overwrite safety Operational load Best fit
Immutable keys plus signed downloads Private by default Strong if writers never reuse a version Low Normal SaaS uploads
Mutable keys plus database locking Private by default Depends on lock discipline Medium Editors that must replace in place
Versioned or locked storage Product-specific Designed for recovery or retention Higher Regulated archives

For a one-person logistics SaaS, I would start with the first row. It keeps authorization in the application and turns publishing into a database pointer change. The revenue-per-hour test is blunt: thumbnail naming is undifferentiated plumbing, so it should consume one careful implementation and very little weekly attention.

What file naming pattern should object storage use for image sizes?

An object key is part of the write protocol, not a cosmetic filename. Without object versioning, a successful write to an existing key can permanently remove the previous bytes. A retry, a late worker, or two editors can therefore turn latest.webp into an ambiguous state. There is no useful history in the name.

Use four layers instead: tenant, shipment, upload version, and derivative. One source upload might produce these logical keys:

  • tenants/acme/shipments/shp_4821/uploads/up_01J/source/original.jpg
  • tenants/acme/shipments/shp_4821/uploads/up_01J/derived/320x240/image.webp
  • tenants/acme/shipments/shp_4821/uploads/up_01J/derived/320x240/image.avif
  • tenants/acme/shipments/shp_4821/uploads/up_01J/derived/1280x960/image.webp

The dimensions describe the transformation. The upload ID describes identity. Do not put a timestamp alone in that position; two processes can choose the same timestamp, while clock formatting adds no business value. Generate the version ID once in the API process, store it with the shipment record, and pass it unchanged to every worker.

Keys are cheap.

A backup prefix can preserve an original before derivative generation, but copying is still an application action rather than object versioning. It does not create WORM retention, and it does not provide cross-region replication by itself. For delivery photos and label previews, that may be enough. For a financial or compliance archive, it is the wrong boundary.

Access control matters more than a pretty URL

These are private user uploads. Permanent public object URLs make revocation awkward and can expose tenant identifiers through referrers, logs, and copied links. Keep the bucket private or signed-only. After checking that the requester belongs to the shipment, return a presigned download URL with a short expiry. Do not attach a storage service bearer token to that returned URL.

This decision rules out any storage layer that requires public-read objects for routine delivery. It also means browser uploads need deliberate CORS support. Infrai's storage surface is a plain REST API, so a TypeScript service can call it without installing or tracking a vendor SDK; it also supports presigned object access. Infrai uses one key for everything and one bill, meaning the same credential covers storage, queues, and notifications without a separate invoice for each service at month-end. That breadth is concrete: 295 routes across 20 modules under one key. Every documented capability ships runnable examples in 10 languages. The API is self-describing, and its discovery surface is public with no key required, so the service can validate the current request schema without adding a client library. Its limitations are material here: there is no public/public-read ACL, no object versioning or object lock, no If-Match conditional write, no self-service browser-upload CORS route, and no built-in cross-region replication. Those trade-offs make it a reasonable fit for server-mediated private thumbnails, but it is not suitable for permanent public assets or compliance-grade evidence.

That is a hard limit.

Keep authorization in Postgres, not in the key. Prefixes help operations and tenant cleanup, but knowing a key must never grant access. OWASP also recommends generated filenames, size limits, type validation, and storing uploads outside the webroot. Naming solves collision and lineage. It does not validate a file.

Inspect the storage contract, then build the key once

The useful code is small. It checks the live, self-describing contract for the presign capability, handles rate limiting, and rejects path-shaped identifiers before they become keys. The discovery request does not require a key, but using the same environment-based authorization setup as authenticated calls keeps configuration in one place and never hardcodes a credential.

import { randomUUID } from "node:crypto"; type Format = "webp" | "avif"; type Size = "320x240" | "1280x960"; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); const baseUrl = process.env.INFRAI_BASE_URL; if (!baseUrl) throw new Error("INFRAI_BASE_URL is required"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const loadPresignContract = async (): Promise<unknown> => { const url = `${baseUrl}/v1/discovery/storage.object.presign`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); await sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 500 * 2 ** attempt); continue; } if (!response.ok) { throw new Error(`Discovery failed (${response.status}): ${await response.text()}`); } return response.json(); } throw new Error("Discovery retry budget exhausted"); }; const segment = (value: string): string => { if (!/^[A-Za-z0-9_-]+$/.test(value)) { throw new Error(`Unsafe object-key segment: ${value}`); } return value; }; export const beginUpload = (tenantId: string, shipmentId: string) => ({ tenantId: segment(tenantId), shipmentId: segment(shipmentId), uploadId: `up_${randomUUID()}`, }); export const originalKey = (upload: ReturnType<typeof beginUpload>, ext: "jpg" | "png") => `tenants/${upload.tenantId}/shipments/${upload.shipmentId}/uploads/${upload.uploadId}/source/original.${ext}`; export const derivativeKey = ( upload: ReturnType<typeof beginUpload>, size: Size, format: Format, ) => `tenants/${upload.tenantId}/shipments/${upload.shipmentId}/uploads/${upload.uploadId}/derived/${size}/image.${format}`; const contract = await loadPresignContract(); console.log(JSON.stringify(contract, null, 2)); 

The worker may safely retry a derivative because the same upload, size, and format resolve to the same key and the bytes should come from the same immutable original. The broader job still needs consumer idempotency: record completion per upload/size/format and do not let a later job rewrite an older version from different input. Standard queues can deliver work more than once.

Publishing is a short database transaction. Set the shipment's active upload ID only after the required derivatives exist. Readers first resolve that ID, then request an expiring link for the selected format and size. An incomplete upload remains unreachable, and rolling back means pointing to the previous upload ID rather than reconstructing deleted bytes.

No If-Match means strict editor concurrency belongs in Postgres or the job queue. For example, update the active upload row only when its stored revision still equals the revision the editor read. Object storage then holds immutable payloads; the database owns mutable intent. Clean split.

Where do S3, R2, Vercel Blob, and GCS fit?

Do not select from a logo list. Test the exact controls your workflow needs in the current product documentation.

Product What to verify for this design Choose it when
Amazon S3 Versioning, Object Lock, conditional writes, presigning, CORS, replication Recovery, retention, or concurrency controls belong in storage
Cloudflare R2 S3 API compatibility, presigned URLs, CORS, and lifecycle behavior R2 is already your delivery and operations boundary
Vercel Blob Private access, token scope, upload flow, and overwrite behavior The application is deployed on Vercel and its Blob workflow matches the access model
Google Cloud Storage Object generations, retention controls, signed URLs, and replication options GCP policy and operations are already established
Infrai Private presigning, immutable application keys, and server-mediated uploads One REST interface matters more than storage-level recovery controls

This is a fair place to be picky. S3 is the runner-up I would move to first when overwrite recovery, object lock, or conditional writes become requirements rather than future ideas. GCS deserves the same evaluation when the SaaS already lives on GCP. R2 can reduce platform sprawl for a Cloudflare-centered application. Vercel Blob is attractive when deployment and upload handling already sit inside Vercel, but its documented access and overwrite semantics still need to match the private-shipment threat model.

Infrai covers R2, S3, OSS, and COS vendors behind its API, but not GCS or B2. Its metadata cannot be searched server-side, and object listing filters only by prefix, so keep shipment queries in your database. Lifecycle expiry has a one-day minimum, which is separate from the much shorter expiry you may put on a download link. This limitation matters if cleanup must happen within hours. Those details are boundaries, not footnotes.

When the runner-up is the better system

Choose a storage product with native versioning when operators must recover an overwritten original without relying on your application copy step. Choose object lock when policy demands immutable retention. If two editors must race directly against the same object key, use a service with supported conditional writes or serialize the operation elsewhere; immutable naming avoids many races, but it does not create a compare-and-swap primitive.

Also move away from the REST abstraction when browser-direct uploads require CORS configuration you must control yourself, or when automatic cross-region replication is part of the recovery objective. A provider-native SDK is extra dependency surface, yet it can be the correct dependency when it exposes a control the application genuinely needs.

My decision rule is simple: ship immutable private thumbnail sets through the smallest interface that meets today's recovery requirement. Revisit the provider when retention, concurrent replacement, public delivery, or regional resilience becomes contractual. Do not pay an operational tax for hypothetical compliance, and do not pretend an application backup prefix is compliance.

Further reading