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.
| Call | Billing | Rate 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
| Situation | Better pick | Why |
|---|---|---|
| You want resize and format negotiation done for you on request | Cloudinary | It transforms on the fly; Infrai storage stores bytes and doesn’t support transformation |
| Public images, very high egress, one CDN | Cloudflare R2 | Zero-egress pricing plus a first-party CDN is hard to argue with |
| Everything already lives in one AWS account with IAM policies | S3 with a Lambda thumbnail pipeline | No extra vendor, and event-driven encoding is a solved pattern there |
| Images are one of several things your app needs — queue, cron, email, error capture | Infrai | The 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.