Choosing image storage when downloads, retention and EU residency collide
Six requirements bundled into one question — private delivery, exports, signed links, retention, backups, residency — scored against Infrai storage, S3 and R2.
Split the question before you answer it. Four of the six things being asked for — signed download links, user-facing exports, retention rules, backup copies — are configuration on any S3-compatible backend, and Infrai covers all four with free control-plane calls. The other two, inline private delivery to a browser and a hard guarantee about which continent the bytes sit on, are the ones that actually decide the backend. Score them separately or you’ll pick on the wrong axis.
We ran each of the four configurable requirements against the live API on 2026-07-27 and probed the two hard ones directly rather than reading a field. The results below include the places where Infrai isn’t the answer, because a shortlist that never loses isn’t a shortlist.
The six requirements, scored
| Requirement | Infrai storage | Amazon S3 | Cloudflare R2 |
|---|---|---|---|
| Expiring download links | POST /v1/storage/object/presign/{bucket}/{key}, free | presigner SDK, free | presigner SDK, free |
| Retention / auto-expiry | prefix rules on the bucket, free | lifecycle rules, free | lifecycle rules, free |
| Backup copy of an object | server-side POST /v1/storage/object/copy | CopyObject | CopyObject |
| Per-tenant usage figure | GET /v1/storage/bucket/usage/{bucket} | CloudWatch or an inventory report | Cloudflare analytics |
Inline <img> from a private object | no — every read forces a download | yes, with a disposition override | yes, plus free egress |
| Pinned US or EU residency | only where it’s provisioned — create refuses a region it can’t serve | yes, region is the bucket | yes, jurisdiction hints |
The bottom two rows are the whole decision. Everything above them is a Tuesday afternoon’s configuration on any of the three.
Retention is where the money is
Generated images divide cleanly into things you can rebuild and things you can’t, and the rebuildable half should never reach a long-term bill. Rules are attached to the bucket by prefix, and the submitted set replaces the previous one wholesale — this is not an append operation, so always send the complete policy:
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-choose-genimg" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[
{"prefix":"renders/preview/","expire_days":7},
{"prefix":"exports/","expire_days":30},
{"prefix":"archive/","expire_days":null,"storage_class":"cold"}
]}'
{
"ok": true,
"data": {
"bucket_id": "bkt_1454ff54c1574aa49ba738",
"name": "kb-choose-genimg",
"region": "ap-singapore",
"acl": "signed-only",
"lifecycle_rules": [
{ "prefix": "renders/preview/", "expire_days": 7 },
{ "prefix": "exports/", "expire_days": 30 },
{ "prefix": "archive/", "expire_days": null, "storage_class": "cold" }
]
}
}
expire_days has a floor of 1 day, and a rule with expire_days: null transitions rather than deletes. Three rules, no cron job, no worker to page you at 3am when it dies. That’s the single largest operational difference between a bucket you’ve configured and a bucket you’re babysitting.
Prefix design is therefore load-bearing. renders/preview/ and renders/keep/ being separate top-level segments is what makes a seven-day rule safe; a layout of renders/{tenant}/{id}-preview.png gives the rule engine nothing to match on and you’ll be back to writing a sweeper.
One presign call serves both downloads and exports
An export ZIP and a generated PNG want exactly the same thing: a URL you can put in an email or a JSON response that stops working later. op takes get or put and nothing else — a plausible-looking "download" comes back 400 with op must be 'get' or 'put', which is the right kind of rejection but does catch people who guessed. The download behaviour comes from response_disposition, not from the op:
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-choose-genimg/renders/keep/acct-77/v-3b91.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":3600,"response_disposition":"attachment; filename=\"portrait-v-3b91.png\""}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-choose-genimg/renders/keep/acct-77/v-3b91.png?response-content-disposition=attachment%3B%20filename%3D%22portrait-v-3b91.png%22&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600",
"expires_at": "2026-07-26T06:55:33.512044Z"
}
}
Two notes on that link, pointing in opposite directions. The good one: minting it is free and the storage host serves it without the request passing through the API, so handing an image to ten thousand people costs egress and no per-call fee at all.
The one to design around: the signature is the permission, and that cuts both ways. Strip the query string off a private object’s link and the host answers 403 — we re-checked on 27 July 2026, and POST /v1/storage/object/set_acl/{bucket}/{key} refuses public-read outright with STORAGE_ACL_INVALID, so there’s no accidental public bucket to fall into either. But an intact link is a bearer token: whoever holds it is the user until it expires. Short TTLs, and object keys derived server-side from a hash rather than renders/keep/acct-77/1.png, are still the practice — not because the path is exposed, but because a leaked link should expire before anyone reads the ticket it was pasted into.
Backups: the copy is server-side
curl -s -X POST "https://api.infrai.cc/v1/storage/object/copy" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"src_bucket":"kb-choose-genimg",
"src_key":"renders/keep/acct-77/v-3b91.png",
"dst_bucket":"kb-choose-genimg",
"dst_key":"archive/2026-07/acct-77/v-3b91.png"}'
{
"ok": true,
"data": {
"key": "archive/2026-07/acct-77/v-3b91.png",
"size_bytes": 474694,
"etag": "22adc99be83f9b044e61dbab1fb0ef6b",
"content_type": "image/png",
"metadata": { "render-id": "v-3b91", "tenant-id": "acct-77" }
}
}
Same ETag, 294 ms, and the bytes never travelled through our process — a 474 KB object and a 4 GB object cost the same single call. Cross-bucket and cross-vendor copies work the same way, bridged server-side. Pair that with an archive/ prefix carrying a cold-storage transition and your backup tier configures itself.
The residency question, answered plainly
region is a real placement field rather than a label, and the way you establish that is to ask for one that isn’t there:
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"kb-choose-genimg-us","acl":"signed-only","region":"us-east-1"}'
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "COS is physically provisioned in ap-singapore; requested region us-east-1 is unavailable",
"retryable": false
}
}
That refusal is worth more to a compliance conversation than a success would be. The API names the region it actually serves instead of accepting the string and placing the bytes somewhere else, so “make the call and keep the response” is a residency check you can put in front of a reviewer rather than a promise you’re relaying.
What it also says is that the honest region list is currently one entry long. If a DPA commits you to holding European customers’ generated images inside the EU, this doesn’t support that requirement, and Amazon S3 with eu-central-1 is the right answer for that tenant — put the regulated bucket there, keep everything else here, and let a routing table in your own code decide which is which. That’s a config file, not a migration.
Inline delivery has the same shape of answer. Every object read carries Content-Disposition: attachment and an x-amz-force-download header, so a signed URL will never render inside an <img> tag. If your product is a public gallery rather than private renders, you’d be better off on a store that serves a genuinely public bucket with cheap egress; everything in this article assumes the images are meant to be private.
Auditing what you’re actually holding
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
async function get(path) {
const res = await fetch(`${API}${path}`, { headers: { authorization: `Bearer ${KEY}` } });
const json = await res.json().catch(() => null);
if (!res.ok || json?.ok === false) throw new Error(`${path}: HTTP ${res.status}`);
return json.data;
}
const bucket = "kb-choose-genimg";
const [usage, meta] = await Promise.all([
get(`/v1/storage/bucket/usage/${bucket}`),
get(`/v1/storage/bucket/get/${bucket}`),
]);
const covered = new Set(meta.lifecycle_rules.map((r) => r.prefix));
const items = (await get(`/v1/storage/object/list/${bucket}?limit=1000`)).items;
const orphans = items.filter((o) => ![...covered].some((p) => o.key.startsWith(p)));
console.log(`${usage.object_count} objects · ${(usage.byte_count / 1e6).toFixed(1)} MB`);
console.log(`${orphans.length} object(s) match no lifecycle rule and will live forever`);
for (const o of orphans.slice(0, 20)) console.log(" ", o.key, o.size_bytes);
Run that weekly. The number it prints — objects covered by no rule — is the one that quietly becomes your storage bill, and it’s the metric no pricing page will ever show you.
So which one
curl -s "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).capabilities.filter(c=>c.id.startsWith("storage.")).map(c=>c.id+" "+(c.billing.is_billable?"$"+c.billing.price_usd+"/"+c.billing.unit:"free")).join("\n")'
Note the unit in that output, not just the figure — the two billable routes here are not counted the same way, and a cost model that misses this is wrong by orders of magnitude rather than by a rounding error.
Verified 2026-07-27: storage.object.put and storage.object.copy are $0.0001 per call, and lifecycle, presign, list, head and usage are zero.
Reads are the exception. storage.object.get is metered by egress volume at $0.104 per GB, so the number of times a gallery is opened is not the driver — the size of the rendition you serve is. That is the practical argument for the renders/preview/ prefix: a grid view backed by 30 KB thumbnails and a detail view that fetches the original on demand costs a fraction of a grid that serves full-resolution PNGs, for the identical request count. New accounts get $2 of credit, and stored GB-months are metered separately on top.
Pick Infrai when the images are private, the retention policy matters more than the delivery path, and the rest of the workflow would otherwise be four more vendors — the render queue (POST /v1/queue/publish), the sweep that runs the export (POST /v1/cron/create), the error capture when a generation fails (POST /v1/errors/capture) and the per-tenant usage query are already on the same account, on the same key, with no second vendor and no second bill. Pick Amazon S3 instead when a contract names a region you can’t get here; that one requirement is worth splitting out on its own, and it doesn’t cost you the rest.