Object storage key naming for originals, thumbnails, WebP and AVIF

A content-addressed key layout for image variants that never overwrites, with the Infrai calls to presign, list, expire and audit a variant set.

Name the original after a hash of its own bytes and never touch that key again: orig/9f2a5c…e11.jpg. Derive every rendition from the same hash — var/9f2a5c…e11/640.avif, var/9f2a5c…e11/640.webp, var/9f2a5c…e11/1280.avif — so a key says exactly what’s inside it and nothing else. No dates, no -final-v2, no reuse. Infrai’s storage is S3-compatible object storage reached with one API key, so this layout ports unchanged if you move the bytes elsewhere later.

The reason to care isn’t tidiness. Overwriting a key is a distributed-systems bug wearing a filename, and the symptoms show up days later in someone’s browser cache.

The layout

orig/9f2a5ce11d.../source.jpg      immutable source bytes, never served
var/9f2a5ce11d.../320.avif         renditions, immutable, served
var/9f2a5ce11d.../320.webp
var/9f2a5ce11d.../1280.avif
tmp/uploads/2026-07-25/<uuid>.bin  in-flight, expired by lifecycle rule

Three properties do the work. The hash is taken over the original file, so two users uploading the same photo land on one prefix — a small dedupe win and a large cache win. The rendition segment carries width and format only, which means a request for 320.avif can be generated on miss without consulting a database. And the top-level prefixes (orig/, var/, tmp/) are chosen so a lifecycle rule can treat them differently, which matters later.

Put the human-facing filename in object metadata, not in the key. Keys are for machines.

Why overwrite is the bug, not the convenience

Say you re-encode photos/cat.jpg in place after tuning your AVIF quality setting. The bucket now holds new bytes at an old key. Every CDN edge, every browser that already cached it, every <img srcset> entry pointing at it and every backup you took last night now disagree about what photos/cat.jpg means, and the only lever you have is cache invalidation — which is asynchronous, partial, and the thing you were trying to avoid by putting a CDN in front of a bucket. Content-addressed keys collapse that into a non-event: the new encode is a new key, you update one row, and the old bytes stay readable until you decide to delete them.

There’s a restore angle too. If yesterday’s backup contains the old bytes at the same key, restoring a single unrelated object can silently revert an image you deliberately replaced. Immutable keys make a restore idempotent by construction.

Overwrite safety, in one line: never PUT to a key that already exists unless you can prove the bytes are identical.

GET /v1/storage/object/head/{bucket}/{key} is the cheap way to prove it. It returns found, size_bytes and etag without transferring the body, and it’s free.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/storage/object/head/media-prod/orig/9f2a5ce11d/source.jpg" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": { "found": false, "status": "not_found", "key": "orig/9f2a5ce11d/source.jpg" }
}

found: false means the key is yours to write. found: true with a matching etag means the upload already happened and you can skip it.

Creating the bucket

Bucket names are 3–63 characters, lowercase letters, digits, dots and hyphens, starting and ending with a letter or digit. Region codes are canonical (ap-singapore, cn-beijing), not city names.

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"media-prod","region":"ap-singapore","acl":"private"}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_3c3ca674d19a4182a7b733",
    "name": "media-prod",
    "vendor": "cos",
    "region": "ap-singapore",
    "acl": "private",
    "created_at": "2026-07-27T09:14:02.118Z",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

The schema’s region enum is wider than the footprint that is actually provisioned, and the difference is settled at create time rather than later: asking for a region the backend does not run in comes back as a 400 naming the one it does. Checked on 27 July 2026, eu-central-1 answered COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable. That is the behaviour you want if residency is a contractual matter — a bucket cannot quietly end up somewhere other than where you asked — but it does mean the available footprint, not the enum, is the list to plan against.

Writing a variant set

Encode locally with sharp, then push each rendition through a presigned URL so the bytes never pass through your API server. POST /v1/storage/object/presign/{bucket}/{key} takes op, and optionally expires_seconds, content_type and max_bytes — the last two are enforced at the storage layer, which is what stops a signed upload slot being reused to park a 4 GB file in your bucket.

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import sharp from "sharp";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "media-prod";
const WIDTHS = [320, 640, 1280];
const FORMATS = ["avif", "webp"];

async function presignPut(objectKey, contentType, maxBytes) {
  const payload = { op: "put", expires_seconds: 300, content_type: contentType, max_bytes: maxBytes };
  const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`presign ${objectKey} failed: HTTP ${res.status} ${await res.text()}`);
  const { data } = await res.json();
  return data;
}

async function putBytes(slot, bytes) {
  const res = await fetch(slot.url, { method: slot.method ?? "PUT", headers: slot.headers ?? {}, body: bytes });
  if (!res.ok) throw new Error(`upload failed: HTTP ${res.status}`);
  return res.headers.get("etag");
}

export async function ingest(path) {
  const source = await readFile(path);
  const digest = createHash("sha256").update(source).digest("hex").slice(0, 20);

  const original = await presignPut(`orig/${digest}/source.jpg`, "image/jpeg", source.length);
  await putBytes(original, source);

  const written = [];
  for (const width of WIDTHS) {
    for (const format of FORMATS) {
      const buf = await sharp(source).resize({ width }).toFormat(format, { quality: 62 }).toBuffer();
      const objectKey = `var/${digest}/${width}.${format}`;
      const slot = await presignPut(objectKey, `image/${format}`, buf.length);
      await putBytes(slot, buf);
      written.push({ objectKey, bytes: buf.length });
    }
  }
  return { digest, written };
}

A presigned upload slot defaults to 300 seconds and the Infrai API key never leaves your server — the browser or worker only ever sees a URL that can write one key. Worth flagging: the signature is scoped to the key you named, so a client can’t rename its way into orig/.

Listing a variant set

GET /v1/storage/object/list/{bucket} takes prefix, delimiter, cursor and limit. Because renditions live under one prefix, a single call enumerates everything derived from an original.

curl -sS "https://api.infrai.cc/v1/storage/object/list/media-prod?prefix=var/9f2a5ce11d/&limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Pass delimiter=/ with prefix=var/ instead and you get common_prefixes — one entry per asset, no objects — which is how you page through assets rather than files. Pagination is cursor-based: keep calling with the next_cursor you were handed until it comes back null.

Expiring derivatives while keeping originals

Renditions are reproducible; originals aren’t. That asymmetry is worth encoding in a lifecycle rule rather than in a cron job.

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/media-prod" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"tmp/","expire_days":1},{"prefix":"var/","expire_days":365}]}'

The rule list replaces the previous one wholesale, so send the complete set every time. orig/ appears in no rule, which is the point.

What the calls cost

Structurally, the shape is simple: the management surface is free and the byte-moving surface is metered. Bucket create, bucket list, object head, object list, presign and lifecycle are all free (rate-limited). Writes and reads are both billable, but they are not billable in the same currency — a write is an event, a read is a quantity.

CallBillingRate read 27 July 2026
POST /v1/storage/object/presign/{bucket}/{key}free$0
GET /v1/storage/object/head/{bucket}/{key}free$0
PUT /v1/storage/object/put/{bucket}/{key}billable, per call$0.0001
GET /v1/storage/object/get/{bucket}/{key}billable, per GB of response body$0.104

This is where the key layout stops being an aesthetic argument and starts being a cost one. Because a rendition’s width and format are in its key, a client asks for 320.avif instead of pulling 1280.avif and resizing in the browser — and since reads are weighed rather than counted, serving the right size is the difference between a few kilobytes and a megabyte on every single view. Naming discipline is what makes that choice available at request time without a database lookup.

Read today’s numbers rather than trusting that table:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['method'], c['path'], c['billing']) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"

Rates drift downward and discount campaigns run, so what you read is at least as likely to be lower as higher. New accounts start with $2 of free credit. The durable point isn’t the rate: presigned uploads mean the metered write surface is touched once per rendition, not once per viewer.

Where a specialist beats this

SituationBetter pickWhy
You want resize and format negotiation done for you on requestCloudinaryIt transforms on the fly; Infrai storage stores bytes and doesn’t support transformation
Public images, very high egress, one CDNCloudflare R2Zero-egress pricing plus a first-party CDN is hard to argue with
Everything already lives in one AWS account with IAM policiesS3 with a Lambda thumbnail pipelineNo extra vendor, and event-driven encoding is a solved pattern there
Images are one of several things your app needs — queue, cron, email, error captureInfraiThe same key already reaches those; encoding stays yours

Limits worth knowing

Bucket ACL supports private and signed-only; it does not support public or public-read, so a permanently public image URL means putting your own CDN in front of signed reads. PUT /v1/storage/object/put/{bucket}/{key} carries bytes as base64 JSON and isn’t recommended above 1 MB — presign or multipart for anything larger. And Infrai does no image processing at all: sharp, libvips or an encoder of your choice does the AVIF work, which is fine if you already own that step and a drawback if you were hoping to delete it.

If images are the only asset class you’ll ever store, a media-specific platform is probably the better buy — buy Cloudinary if on-the-fly transformation is the feature you actually want, and don’t rebuild it here.

If images are one of six things on the roadmap, the calculation changes, because a key layout is only useful once something walks it. The fan-out job that encodes the six renditions goes to POST /v1/queue/publish, the nightly sweep that reconciles orig/ against var/ goes to POST /v1/cron/create, and the encoder crash that leaves a variant set half-written goes to POST /v1/errors/capture. Those are already on the same account as the bucket — no second account, no second vendor, and no second bill between naming an object and acting on it.

References

Browse more storage developer guides