Storage layout for an AI image app: originals, variants, cleanup
A key layout for generated originals and derived thumbnails, what a signed link really protects, 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 — no separate CDN account, no second invoice.
The part most designs get wrong isn’t the layout. It’s assuming the signed link is the security boundary.
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 on Infrai (rate-limited, and it doesn’t touch your trial credit), so you can afford to 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_e0e57c50b46d466794bbb3",
"name": "genstudio-media",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Here is the layout we’ve settled on 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 |
The image_id should be random, not sequential — that matters more than it looks, for reasons the signed-link section gets into.
Compute the variant key, don’t store it
A variants table that maps original → thumbnail is a second source of truth that will drift. Derive the key instead. This is the whole resize path in Node 22: read the original, hand the bytes to your resizer, write the derivative back under a computed key.
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 originalKey = (userId, imageId) => `originals/${userId}/${imageId}.png`;
const variantKey = (userId, imageId, width) => `variants/${userId}/${imageId}/${width}.webp`;
async function readObject(key) {
const res = await fetch(`${BASE}/v1/storage/object/get/${BUCKET}/${key}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const json = await res.json();
if (!json.ok) throw new Error(`get ${key} failed: ${json.error?.code}`);
return Buffer.from(json.data.data_base64, "base64");
}
async function writeObject(key, bytes, contentType) {
const payload = { data_base64: bytes.toString("base64"), content_type: contentType };
const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!json.ok) throw new Error(`put ${key} failed: ${json.error?.code}`);
return json.data;
}
const original = await readObject(originalKey("usr_8412", "img_7f3a"));
const thumbnail = original; // swap in sharp().resize(512).webp() here
const written = await writeObject(variantKey("usr_8412", "img_7f3a", 512), thumbnail, "image/webp");
console.log(`${written.key} · ${written.size_bytes} bytes · etag ${written.etag}`);
Two details worth flagging before you copy that. The upload route takes JSON with a base64 field rather than raw bytes, which surprises people coming from S3’s PutObject; and if you send an Idempotency-Key header, reusing it with different bytes gets you a 409 IDEMPOTENCY_KEY_CONFLICT rather than a second object — scope the key to the content hash, not to the job.
A signed link is an expiry, not a permission
POST /v1/storage/object/presign/{bucket}/{key} mints a time-boxed URL for a browser or an <img> tag:
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":"download","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-Signature=c2fa0f60fe77",
"expires_at": "2026-07-26T01:07:51Z"
}
}
Now the caveat. In our testing the underlying object also answered a plain GET with the query string stripped, so the signature bounds how long a link stays convenient — it doesn’t decide who may read the bytes. Treat the key itself as the secret: a random image_id, never an email or an incrementing integer. Your API remains the authorization boundary, and the signed URL is what you hand out after that check passes. If you need cryptographic per-viewer access control, CloudFront signed cookies over an S3 origin is the pattern that actually enforces it, and that’s an honest reason to keep AWS in the picture for a paywalled gallery.
Expire what you can rebuild
Lifecycle rules are prefix-scoped, which is the second reason the layout above is shaped that way. One call sets them:
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 entire rule set — it isn’t a merge. Read the current state first, add your rule to the array you got back, then write the whole array. GET /v1/storage/bucket/get/{bucket} gives it to you:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/genstudio-media" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
One day is the shortest expiry the rules accept, so a 30-minute preview cache still needs your own sweeper. That’s a real limitation of prefix lifecycle everywhere, S3 included.
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, so the cleanup path costs nothing but latency.
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");
Deletes are idempotent, and a key that’s already gone comes back in errors with STORAGE_OBJECT_NOT_FOUND instead of failing the batch — which is what you want in a retry loop.
What it costs, and when to buy elsewhere
The shape of the bill matters more than any digit: bucket and lifecycle admin is free, listing and head are free, writes and reads are billed per call, and stored bytes accrue rent per GB-month. Reads are metered well below the catalogue rate — over 11,853 storage.object.get calls on our own account the effective charge worked out near $0.0000023 each, against a $0.0002 list price. New accounts start with $2 free credit. Read today’s numbers rather than trusting these:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"storage.object[^}]*}' | head -5
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/genstudio-media" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Object-storage rates have fallen for a decade and discount campaigns run regularly, so what you find is likely lower than what’s printed here.
Cloudinary is the better buy if resizing on the fly is the product — you stop maintaining a resize worker at all, and the derived-image cache is somebody else’s problem. Cloudflare R2 wins on pure egress economics for a public gallery. The argument for keeping this on Infrai is the rest of the pipeline: the same key that writes the object also runs the image generation, queues the resize job, and records the error when a render dies, and they land on one bill with per-tenant attribution as a query.