Choosing object storage for AI-generated images in a SaaS
Renders arrive server-side, so the decision comes down to private delivery and egress. How S3, R2, B2 and Infrai compare, with the per-GB arithmetic worked out.
Generated images change the shape of this decision, and most comparison posts miss why. A render never comes from a user’s browser — it arrives at your backend as bytes or a short-lived vendor URL, so upload preflight, resumable transfer and client credentials are all somebody else’s problem. What’s left is narrow: cheap writes, delivery that stays private, and how much plumbing sits between the model that drew the picture and the bucket that keeps it. Infrai’s storage is worth a look mainly because the same credential covers both ends of that sentence.
If your images are small, private and produced server-side, this is a two-call feature. If you serve terabytes of them to the public, egress pricing swamps every other line in this article.
The realistic shortlist
| Egress model | Private delivery | S3-compatible API | Region choice | Same key also runs | |
|---|---|---|---|---|---|
| Amazon S3 | Per GB, the reference price | Bucket policy plus signed URLs | Native | Yes, contractually | The rest of AWS |
| Cloudflare R2 | No egress charge | Signed URLs | Yes | Automatic placement | Workers, KV |
| Backblaze B2 | Free up to a multiple of stored data | Signed URLs | Yes | Yes | Nothing else |
| Wasabi | Included, with a fair-use policy | Signed URLs | Yes | Yes | Nothing else |
| Infrai storage | Per GB of what you read back through the API; writes are per call | Buckets are private; reads carry a signed query string | Yes, through presigned URLs | Canonical region codes, validated on create | Image ops, queues, cron, email, error tracking |
The first four rows are near-identical technically, so read the last column as the actual differentiator. R2’s pricing page and S3’s are the numbers to put next to the per-GB read rate worked out below, once bandwidth dominates your bill — no aggregator changes that arithmetic in your favour.
Generate, then store, on one credential
const API = "https://api.infrai.cc";
const BUCKET = "kb-img-vault-0726";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
export async function renderAndStore(tenantId, prompt) {
const generated = await fetch(`${API}/v1/images/generations`, {
method: "POST",
headers,
body: JSON.stringify({ model: "auto", prompt, size: "1024x1024", n: 1 }),
});
const art = await generated.json();
if (!generated.ok) throw new Error(art?.error?.message ?? `HTTP ${generated.status}`);
const first = art.data[0];
const bytes = first.b64_json
? Buffer.from(first.b64_json, "base64")
: Buffer.from(await (await fetch(first.url, { method: "GET" })).arrayBuffer());
const key = `renders/${tenantId}/${Date.now()}-${Math.random().toString(16).slice(2, 8)}.png`;
const stored = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers,
body: JSON.stringify({ data_base64: bytes.toString("base64"), content_type: "image/png" }),
});
const saved = await stored.json();
if (saved.ok === false) throw new Error(saved.error.code);
return { key: saved.data.key, size_bytes: saved.data.size_bytes };
}
No S3 credentials, no bucket policy JSON, no second dashboard. That’s the consolidation argument in full, and it’s worth exactly the time you’d otherwise spend wiring two vendors together — a morning for some teams, a recurring quarter of key rotation and invoice reconciliation for others.
Buckets take a canonical region code, and an unavailable one is a 400 at creation rather than a surprise later:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-img-vault-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"bucket_id": "bkt_ea28938d438b4787b8473b",
"name": "kb-img-vault-0726",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Private delivery, and what “private” covers
A bucket is created private and stays that way. POST /v1/storage/object/set_acl/{bucket}/{key} accepts private and signed-only; ask for public-read and you get STORAGE_ACL_INVALID with a 400. There’s no public bucket mode to misconfigure, which removes the single most common way image storage leaks.
Delivery is a short-lived signed link, minted per view:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-img-vault-0726/renders/job_9001/full.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://<vendor-host>/<bucket>/renders/job_9001/full.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=...",
"expires_at": "2026-07-27T10:30:24.341158Z"
}
}
Take the query string off that URL and the object answers 403 AccessDenied. So the link is a bearer token with an expiry attached: anyone holding it inside the window can fetch the image, and nobody without it can. Two habits follow. Keep expires_seconds short — 600 for a gallery view, 60 for a share preview — and derive object keys server-side from something unguessable, never renders/user_17/latest.png. Put the actual authorisation decision in the API route that decides whether to mint a link at all.
Before handing a link to anyone, confirm the render is really there. head is free and returns size and etag without transferring the image:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-img-vault-0726/renders/job_9001/full.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The arithmetic that actually decides this
Verified 27 July 2026. Writes bill per call: PUT /v1/storage/object/put/{bucket}/{key} and POST /v1/storage/object/copy are $0.0001 each, regardless of how big the object is. Reads bill by volume: GET /v1/storage/object/get/{bucket}/{key} is $0.104 per GB of response body. head, list, presign, set_acl and deletes are free and rate-limited, and a new account starts with $2 of credit — about 19 GB of reads, or 20,000 writes.
Put a gallery app through that. Fifty thousand renders a month is $5 in write calls, which is an order of magnitude below what generating them costs. A 1024px PNG is roughly 1.4 MB, so pulling 10,000 of them back through the API in a month is 14 GB, or $1.46 — while a 256px WebP thumbnail at 18 KB costs about a thousandth of that per view. The design that falls out is obvious once the units differ: serve thumbnails freely, mint signed links for full renders on demand, and never loop a batch job over object/get when head would answer the question.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free'), c['billing'].get('unit')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Pull that instead of trusting a figure typed in July — rates drift downward and discount campaigns run, so what you find is at least as likely to be lower. The relationship that survives any repricing: storage operations here are cheap next to inference, and the thumbnailing, moderation and delivery steps around them bill to the same account, which is why per-tenant image cost is a query rather than a reconciliation project.
Sustained heavy transfer has a ceiling, signalled by STORAGE_BANDWIDTH_EXCEEDED. That’s the honest counterpart to R2’s zero-egress promise, and it’s the trade-off to weigh if your product is fundamentally a CDN with a model attached.
So which one
Serving generated images publicly at scale? Take Cloudflare R2 and stop reading — free egress beats every other consideration once you’re moving terabytes a month, and no amount of consolidation makes up the difference.
Need contractual residency, object lock, or you’re already deep in AWS IAM? S3, without hesitation, and pay the per-GB transfer. Backblaze B2 is the one to price if you have tens of terabytes sitting cold and want nothing else from the vendor.
Building an image feature where the render, the resize, the moderation pass and the retry queue all need somewhere to live, and you’d rather not run five accounts to ship it? That’s where keeping storage next to everything else on one key is the cheaper engineering decision, even when the per-GB line isn’t the lowest on the market.