Storage layout for an AI image app: originals, variants, cleanup
A key layout for generated originals and derived thumbnails, how signed reads are actually enforced, and which prefixes to hand to a lifecycle rule.
One private bucket, three prefixes, and a key you can compute rather than look up. Put the model output under originals/, every resize under variants/, anything in flight under tmp/, and let a lifecycle rule delete the two prefixes you can regenerate. On Infrai that’s one bucket call, one lifecycle call, and a PUT per object.
The layout is the easy half. The half that decides whether this design survives contact with a growth curve is where the resize runs and what the bill is shaped like.
One bucket, three prefixes
Per-tenant buckets look tidy on a whiteboard and then you own a lifecycle rule per tenant. Prefixes cost nothing, list cheaply, and give you one place to change retention. Bucket creation is free (rate-limited, and it doesn’t touch the trial credit), so separate environments by bucket and everything else by prefix.
export INFRAI_API_KEY="your_infrai_api_key"
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":"genstudio-media"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_023687386d6c43ad9b2e65",
"name": "genstudio-media",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
New buckets come back private, which is the default you want for generated media. Here’s the layout for generation workloads, with the retention decision attached to each class:
| Prefix | What lands there | Regenerable? | Retention |
|---|---|---|---|
originals/{user_id}/{image_id}.png | raw model output | no — the seed and the model may be gone | until the user deletes it |
variants/{user_id}/{image_id}/{width}.webp | thumbnails, crops, social cards | yes, from the original | 30 days, rebuilt on demand |
tmp/{user_id}/{job_id}/… | previews, half-finished work | yes | 1 day |
Make image_id random rather than sequential. It’s not your access control, but it removes enumeration as a shortcut, and it makes every variant key derivable from the original.
Compute the variant key, and don’t ship a resizer
A variants table mapping original → thumbnail is a second source of truth that will drift. Derive the key instead. And the resize itself doesn’t need a native image library in your worker image: POST /v1/image/resize is on the same key as the bucket, is free within rate limits, and returns the encoded bytes ready to write back.
import { Buffer } from "node:buffer";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const BUCKET = "genstudio-media";
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const originalKey = (userId, imageId) => `originals/${userId}/${imageId}.png`;
const variantKey = (userId, imageId, width) => `variants/${userId}/${imageId}/${width}.webp`;
async function json(res, what) {
const out = await res.json();
if (!out.ok) throw new Error(`${what} failed: ${out.error?.code} ${out.error?.message}`);
return out.data;
}
async function makeVariant(userId, imageId, width) {
const src = await fetch(`${BASE}/v1/storage/object/get/${BUCKET}/${originalKey(userId, imageId)}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const original = await json(src, "object.get");
const resized = await fetch(`${BASE}/v1/image/resize`, {
method: "POST",
headers: auth,
body: JSON.stringify({
image: { base64: original.data_base64 },
width,
fit: "inside",
enlarge: false,
format: "webp",
}),
});
const thumb = await json(resized, "image.resize");
const bytes = Buffer.from(thumb.url.split(",")[1], "base64");
const put = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${variantKey(userId, imageId, width)}`, {
method: "PUT",
headers: auth,
body: JSON.stringify({ data_base64: bytes.toString("base64"), content_type: "image/webp" }),
});
return json(put, "object.put");
}
const written = await makeVariant("usr_8412", "img_7f3a", 512);
console.log(`${written.key} · ${written.size_bytes} bytes · etag ${written.etag}`);
image.resize answers with { image_id, url, format, width, height, size_bytes, sha256, ops_applied }, where url is a data: URL — hence the split on the comma. ops_applied comes back as something like ["resize(512x512,fit=inside)", "format_convert(webp,q=90)"], which is handy to log next to the object you wrote.
One detail that catches people coming from S3: the upload route takes JSON with a base64 field, not raw bytes on the wire. Budget for the roughly 33% base64 inflation on large originals, and use the multipart routes past a few hundred megabytes.
Signed reads, and what the signature really buys you
Objects in a private bucket aren’t readable without a signature. Mint one with op: "get":
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/genstudio-media/variants/usr_8412/img_7f3a/512.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.genstudio-media/variants/usr_8412/img_7f3a/512.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=781d338f8d71b87f65e91bf903c70c9b1da1040d128745e46c307945a51925b8",
"expires_at": "2026-07-27T12:03:17.791683Z"
}
}
Fetch that URL and you get the bytes; strip the query string and the same object answers 403. Past expires_at it’s STORAGE_PRESIGN_EXPIRED, so an <img src> in a page a user keeps open for an hour needs a re-mint, not a longer TTL.
The caveat worth internalising: a presigned URL is a bearer token. Whoever holds it can read the object until it expires, and that includes a Slack unfurl bot and a browser extension. Keep TTLs short (600 seconds covers a page render with room to spare), derive keys server-side so they can’t be guessed, and keep your own API as the authorization boundary that decides whether to mint a URL at all. If you need per-viewer cryptographic access that survives link sharing, CloudFront signed cookies over an S3 origin is the pattern built for it, and that’s a fair reason to keep AWS in a paywalled gallery.
Expire what you can rebuild
Lifecycle rules are prefix-scoped, which is the second reason the layout is shaped this way:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/genstudio-media" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"tmp/","expire_days":1},{"prefix":"variants/","expire_days":30}]}'
The call replaces the whole rule set rather than merging, and it validates the keys you send, so read the current state with GET /v1/storage/bucket/get/{bucket}, append to the array you got back, and write it all. One day is the shortest expiry the rules accept — a 30-minute preview cache still needs your own sweeper, which is a limitation of prefix lifecycle at every provider that offers it.
Deleting an image means deleting its fan-out
When a user removes a picture you have one original and an unknown number of derivatives. List by prefix, then batch the deletes — both routes are free.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const BUCKET = "genstudio-media";
async function purgeImage(userId, imageId) {
const prefix = `variants/${userId}/${imageId}/`;
const listed = await fetch(`${BASE}/v1/storage/object/list/${BUCKET}?prefix=${encodeURIComponent(prefix)}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const list = await listed.json();
if (!list.ok) throw new Error(`list failed: ${list.error?.code}`);
const keys = list.data.items.map((o) => o.key);
keys.push(`originals/${userId}/${imageId}.png`);
const res = await fetch(`${BASE}/v1/storage/object/delete_batch/${BUCKET}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keys }),
});
const out = await res.json();
if (!out.ok) throw new Error(`delete_batch failed: ${out.error?.code}`);
console.log(`deleted ${out.data.deleted.length}, errors ${out.data.errors.length}`);
}
await purgeImage("usr_8412", "img_7f3a");
A key that was already gone comes back in errors with STORAGE_OBJECT_NOT_FOUND instead of failing the batch, which is what you want inside a retry loop.
What the bill is shaped like
Get the shape right and the digits matter less. Bucket and lifecycle admin is free, listing and head are free, PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call, and GET /v1/storage/object/get/{bucket}/{key} is metered by egress volume at $0.104 per GB rather than per request — both read on 27 July 2026. Stored bytes accrue rent per GB-month. That pricing shape is why variants/ is worth expiring and why thumbnails should be small: a gallery that serves full-resolution originals to a grid view pays for every one of those gigabytes.
curl -sS "https://api.infrai.cc/v1/discovery?namespace=storage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/genstudio-media" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The first gives each route’s billing block with price_usd and unit; the second gives byte_count and object_count for the bucket, which is the number rent is charged against. Object-storage rates have fallen for a decade and discount campaigns run, so what you read is likely below what’s printed here. For a hot public gallery, put a CDN in front and stop paying origin egress per view — that’s the right answer at any provider.
Cloudinary is the better buy if on-the-fly transformation is the product, with a derived-image cache you never maintain, and a specialist will always be deeper on its one job. The reason to keep this on Infrai is the rest of the pipeline: the same key that writes the object also runs the resize, publishes the job with POST /v1/queue/publish, and records a failed render with POST /v1/errors/capture — no second account, no second SDK, and per-tenant cost across all of it is one query against GET /v1/account/usage.