Storing AI-generated images cheaply: lifecycle rules and signed URLs
Most of an AI image bill is renditions nobody reopens. A prefix layout, the Infrai lifecycle calls that expire them automatically, and how to measure the saving.
The cheapest place to keep AI-generated images is mostly a deletion policy, not a vendor. 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 exactly that, so the regenerable half of your bucket expires on a schedule instead of accumulating until someone notices the invoice.
Per-GB rates across S3-compatible providers land within a few cents of each other. Retention doesn’t.
Decide what’s regenerable before you decide where to put it
Every object in an image app falls into one of four buckets, and the answer to “how long do we keep this” follows from whether you can rebuild it and what rebuilding costs.
| Asset | Prefix | Regenerable? | Rebuild cost | Keep for |
|---|---|---|---|---|
| Final render the user saved | gen/<user>/<date>/ | no — the model is stochastic | a new generation call | forever, or account lifetime |
| Draft the user rejected | gen/drafts/ | no, but nobody wants it | n/a | 30 days |
| Thumbnail / gallery rendition | alongside the render | yes | one sharp encode, ~40ms | forever (it’s tiny) |
| Preview cache, share cards, OG images | cache/preview/ | yes | one encode | 7 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 and rebuilding them is a stampede. Expiring the preview cache and the rejected drafts is where the bytes actually are.
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) and optionally transition_class. It’s free.
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",
"region": "eu-central-1",
"acl": "private",
"created_at": "2026-07-26T00:36:43.566322Z",
"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 that deletes 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-26T00:42:51.913780Z"
}
}
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. This runs against a real bucket and finishes in a couple of seconds for a few thousand objects.
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 once a week and the “why is storage up 40%” conversation takes one 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, 1–1000 per call, and reports missing keys in errors rather than failing the batch.
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.
What the metered calls actually are
Structurally: everything administrative is free and rate-limited, and only two routes are metered per call. Writes bill at $0.0001 and JSON reads at $0.0002 — both verified 26 July 2026 — so reads run about twice writes, and that ratio is the part likely to survive any repricing. Bucket create, lifecycle, list, head, usage, presign and batch delete are all free and don’t consume the $2 of free credit a new account starts with.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); [print(c['id'], c['billing'].get('price_usd','free')) for c in d['capabilities'] if c['id'].startswith('storage.')]"
Read that 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. And the figure isn’t the argument anyway — the argument is that the generation call, the queue that ran it, the cron that sweeps the bucket and the storage holding the result sit behind one key and one invoice, so per-tenant cost is a query rather than a spreadsheet reconciling four vendors.
Delivery: signed links, honestly
Serve renders through a presigned op=get URL minted at render time with a short expires_seconds, not a link stored in your database.
The signature buys you expiry and nothing more. A presigned URL exposes the object’s storage path including your account prefix, so treat it as a convenience, not a permission system: name objects with server-derived unguessable segments (a hash, a random id — never gen/user_17/latest.png), keep expiry short, and put the actual authorisation decision in the API route that decides whether to mint a link at all.
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 that serves millions of views will beat any per-call model on egress alone. Backblaze B2 and Wasabi both undercut on raw per-GB storage if you have tens of terabytes sitting cold and don’t need anything else from the vendor. And if you want the resize, format negotiation and CDN as one product, Cloudinary does that and Infrai doesn’t — there’s no image transformation here at all.
Two limitations to weigh before committing. There’s no way to set bucket CORS, so browsers can’t upload straight into an Infrai bucket; uploads have to come from your server. And 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.