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:

PrefixWhat lands thereRegenerable?Retention
originals/{user_id}/{image_id}.pngraw model outputno — the seed and the model may be goneuntil the user deletes it
variants/{user_id}/{image_id}/{width}.webpthumbnails, crops, social cardsyes, from the original30 days, rebuilt on demand
tmp/{user_id}/{job_id}/…previews, half-finished workyes1 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.

References

Browse more storage developer guides