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-26 and checked the two hard ones by inspection. 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

RequirementInfrai storageAmazon S3Cloudflare R2
Expiring download linksPOST /v1/storage/object/presign/{bucket}/{key}, freepresigner SDK, freepresigner SDK, free
Retention / auto-expiryprefix rules on the bucket, freelifecycle rules, freelifecycle rules, free
Backup copy of an objectserver-side POST /v1/storage/object/copyCopyObjectCopyObject
Per-tenant usage figureGET /v1/storage/bucket/usage/{bucket}CloudWatch or an inventory reportCloudflare analytics
Inline <img> from a private objectno — every read forces a downloadyes, with a disposition overrideyes, plus free egress
Pinned US or EU residencyno — region is recorded, not enforcedyes, region is the bucketyes, 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.

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":"download","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 honest caveats, and they point in opposite directions. The good one: minting a link is free and the link is served by the storage host without going through the API, so handing an image to ten thousand users costs egress and zero per-call fees. The bad one: the signature is an expiry, not a permission. Strip the query string and the object still answers 200. Treat the key as the secret — high-entropy identifiers, never renders/keep/acct-77/1.png — and keep the TTL short.

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

This is where we’d send you elsewhere. region is accepted on bucket creation and echoed back on every read of the bucket record, which reads like a placement control and isn’t one:

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"}'

curl -s "https://api.infrai.cc/v1/storage/bucket/get/kb-choose-genimg-us" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

Both calls report "region": "us-east-1" back to you. Then look at the host inside any signed URL that bucket produces and you’ll find it somewhere else entirely. If a DPA commits you to keeping European customers’ generated images inside the EU, this doesn’t support that, and no field on the API will make it true. Amazon S3 with eu-central-1 or Cloudflare R2 with a jurisdiction hint are the correct answers — you’d be better off putting the regulated tenant’s bucket there and keeping everything else here, which is a routing table in your own code, not a migration.

The same applies to inline delivery. 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 image gallery, R2’s free egress plus a normal public bucket beats anything described here.

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+"/call":"free")).join("\n")'

Verified 2026-07-26, that returns writes and copies at $0.0001 per call, API-mediated reads at $0.0002, and lifecycle, presign, list, head and usage at zero; new accounts get $2 of credit, and storage plus egress are metered separately on top. Read it live rather than from here — these rates have trended downward and discount campaigns run, so today’s figure is likely lower.

Pick Infrai when the images are private, the retention policy matters more than the delivery path, and the rest of your stack — the render queue, the export cron, the error capture, the per-tenant usage query — would otherwise be four more vendors. Pick Amazon S3 when a contract names a region. Pick Cloudflare R2 when the images are public and egress is your dominant line item. Wasabi and Backblaze are worth a look for cold archives if that tier grows past a few terabytes; below that the operational saving of one account and one bill is worth more than the per-gigabyte difference.

References

Browse more storage developer guides