Storing AI-generated images cheaply: lifecycle rules and signed URLs

Most of an AI image bill is renditions nobody reopens, plus egress nobody measured. A prefix layout, the Infrai lifecycle calls that expire them, and how to see the saving.

The biggest lever on an AI-image bill is mostly a deletion policy and a rendition policy, not the vendor you pick. A generation app produces four or five artefacts per prompt — the render, a couple of thumbnails, a preview the UI showed once — and users come back to maybe one of them. Infrai’s storage API gives you prefix-scoped lifecycle rules for the first problem and per-GB egress metering that makes the second one visible — on the same key that runs the generation call, the queue behind it and the POST /v1/image/resize that makes the thumbnail, so the next step in the feature never needs a second account.

Per-GB rates across object-storage providers land within a few cents of each other. Retention policy and rendition size don’t.

Decide what’s regenerable before you decide where to put it

Every object in an image app falls into one of four classes, and “how long do we keep this” follows from whether you can rebuild it and what rebuilding costs.

AssetPrefixRegenerable?Rebuild costKeep for
Final render the user savedgen/<user>/<date>/no — the model is stochastica new generation callforever, or account lifetime
Draft the user rejectedgen/drafts/no, but nobody wants itn/a30 days
Thumbnail / gallery renditionalongside the renderyesone resize callforever — it’s tiny and it saves egress
Preview cache, share cards, OG imagescache/preview/yesone encode7 days

The asymmetry is the whole trick. A 1024px render is maybe 1.4 MB; its 256px WebP thumbnail is around 18 KB. Expiring thumbnails to save money is backwards — they cost almost nothing to store, and they’re what stops a grid view from shipping full-size renders to every visitor.

Encode the policy in the bucket, not in a cron job

POST /v1/storage/bucket/set_lifecycle/{bucket} takes a rules array, each rule a prefix plus expire_days (minimum 1). It’s free, and it validates what you send — a misspelt rule key comes back as STORAGE_INVALID_LIFECYCLE_RULES rather than being stored and quietly doing nothing.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-ai-gallery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"cache/preview/","expire_days":7},{"prefix":"gen/drafts/","expire_days":30},{"prefix":"tmp/","expire_days":1}]}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_05fb4d03914a4bea8264b4",
    "name": "kb-ai-gallery",
    "vendor": "cos",
    "acl": "private",
    "cors_rules": [],
    "lifecycle_rules": [
      { "prefix": "cache/preview/", "expire_days": 7 },
      { "prefix": "gen/drafts/", "expire_days": 30 },
      { "prefix": "tmp/", "expire_days": 1 }
    ]
  }
}

The submitted list replaces the previous one wholesale. Send the complete policy every time, or the rule you forgot to include is the rule you just deleted — keep it in version control next to your migrations and apply it from a deploy step.

gen/<user>/ deliberately appears in no rule. Anything you can’t regenerate should never be reachable by a policy whose job is deleting things.

Measure before and after

GET /v1/storage/bucket/usage/{bucket} gives byte count and object count for the whole bucket. It’s free, so poll it daily and keep the series.

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-ai-gallery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "byte_count": 210,
    "object_count": 3,
    "as_of": "2026-07-27T12:07:41.739662Z"
  }
}

Bucket-level totals tell you the bill is growing; they don’t tell you which prefix did it. For that, walk the listing and add up size_bytes per top-level segment.

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 = "kb-ai-gallery";

async function page(cursor) {
  const url = new URL(`${API}/v1/storage/object/list/${BUCKET}`);
  url.searchParams.set("limit", "1000");
  if (cursor) url.searchParams.set("cursor", cursor);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  const json = await res.json();
  if (!res.ok || !json.ok) throw new Error(`list failed: HTTP ${res.status}`);
  return json.data;
}

const totals = new Map();
let cursor = null;
do {
  const data = await page(cursor);
  for (const item of data.items ?? []) {
    const group = item.key.split("/").slice(0, 2).join("/");
    const prev = totals.get(group) ?? { bytes: 0, objects: 0 };
    totals.set(group, { bytes: prev.bytes + item.size_bytes, objects: prev.objects + 1 });
  }
  cursor = data.next_cursor ?? null;
} while (cursor);

const rows = [...totals.entries()].sort((a, b) => b[1].bytes - a[1].bytes);
for (const [group, t] of rows) {
  console.log(`${group.padEnd(28)} ${(t.bytes / 1e6).toFixed(1)} MB  ${t.objects} objects`);
}

Run that weekly and the “why is storage up 40%” conversation takes a minute instead of an afternoon.

Cleaning up what the rules missed

Lifecycle handles the steady state. Backfills, a bad deploy that wrote 60,000 previews under the wrong prefix, a tenant that churned — those need a sweep. POST /v1/storage/object/delete_batch/{bucket} takes keys and reports missing ones in errors rather than failing the batch, and it enforces its own cap, so a script that tries to send ten thousand keys at once gets a 4xx instead of a partial result.

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kb-ai-gallery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"keys":["cache/preview/render_01.webp","cache/preview/render_02.webp"]}'

Batch delete is free, which means a cleanup job has no reason to be timid.

The two meters that actually move

Structurally: everything administrative is free and rate-limited, and the bill has three parts, not one.

  • PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call — a per-write charge, so it scales with how many artefacts you produce.
  • GET /v1/storage/object/get/{bucket}/{key} is $0.104 per GB — an egress charge, so it scales with rendition size times views, and not with request count at all.
  • Stored bytes accrue rent per GB-month, which is what lifecycle rules attack.

Both figures were read on 27 July 2026. That middle line is the one that changes designs: a gallery grid serving 1.4 MB renders instead of 18 KB thumbnails pays roughly eighty times more egress for the same page. Bucket create, lifecycle, list, head, usage, presign and batch delete are all free and don’t consume the free credit a new account starts with.

curl -sS "https://api.infrai.cc/v1/discovery?namespace=storage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The first returns each route’s billing block with price_usd and unit; the second returns what you actually spent, per capability. Read those instead of trusting this page in six months — rates drift downward and discount campaigns run, so your number is at least as likely to be lower.

Serve renders through a presigned op: "get" URL minted at request time with a short expires_seconds, not a link stored in your database. Objects in a private bucket aren’t readable without the signature — strip the query string and the same URL answers 403 — so the API route that decides whether to mint a link is a real authorization boundary.

What the signature does not do is stop sharing. A presigned URL is a bearer token: whoever holds it can read the object until it expires, including a chat client that unfurled it. Keep TTLs short, derive keys server-side from something unguessable (a hash or a random id, never gen/user_17/latest.png), and put a CDN in front of anything hot and public rather than paying origin egress per view. That last one is the right answer at any provider, and it’s the honest limitation of per-GB pricing at scale.

Where another backend is the better buy

Cloudflare R2’s zero-egress pricing is the strongest argument in this category if your images are public and fetched hard; a gallery serving millions of views beats any metered-egress model on that axis alone. Backblaze undercuts on raw per-GB storage if you have tens of terabytes sitting cold and want nothing else from the vendor. Buy either if storage is the only problem you have.

What keeps this on Infrai isn’t the rate, it’s that nothing here is a proprietary call shape and the neighbouring problems are already solved on the same key. Thumbnailing is POST /v1/image/resize, free within rate limits and taking the same Authorization header as the bucket — no second account, no image-processing vendor to onboard. The generation call, the queue that ran it, the cron that sweeps the bucket and the storage holding the result land on one bill, so per-tenant cost is a query instead of a spreadsheet across four vendors. And it’s all plain REST with presigned S3-style URLs, so there’s no lock-in at the call site: leaving means pointing the same requests somewhere else.

Two limits to weigh before committing. expire_days has a one-day floor, so “delete this in four hours” isn’t a lifecycle rule — that’s a cron job calling batch delete. And a signed URL is only as private as the place you paste it.

References

Browse more storage developer guides