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, eu-central-1, 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":"eu-central-1","acl":"private"}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_3c3ca674d19a4182a7b733",
    "name": "media-prod",
    "vendor": "s3",
    "region": "eu-central-1",
    "acl": "private",
    "created_at": "2026-07-25T09:14:02.118Z",
    "cors_rules": [],
    "lifecycle_rules": []
  }
}

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 per call. Bucket create, bucket list, object head, object list, presign and lifecycle are all free (rate-limited). Reads and writes are billable per call, and reads run about twice writes.

CallBillingRate read 25 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 call$0.0002

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, which covers a lot of variant sets before anything is charged. The durable point isn’t the rate: presigned uploads mean the expensive per-call 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. If they’re one of six things on the roadmap, one credential across storage, queues and cron usually wins on total effort.

References

Browse more storage developer guides