US and EU users on one storage account: where the bytes really sit
Infrai object storage is provisioned in ap-singapore, and the API says so. The routing layer to build anyway, and where the EU half of a residency obligation belongs.
Two questions are hiding inside “half US, half EU”, and they have different answers. If EU personal data has to physically stay in the EU, Infrai object storage isn’t where those bytes go — it’s provisioned in ap-singapore, and bucket/create returns a 400 saying exactly that if you ask for anything else. If what you’re really solving is a Frankfurt user waiting on a 4 MB PDF, that’s a caching problem, and nothing has to move.
So build one routing function with two destinations, and be honest with yourself about which half of your users represents a legal obligation and which represents an annoyance. Infrai gives you one key for the routing layer and everything downstream of it; a jurisdiction guarantee is something you buy from a provider that writes it into the contract.
What region does today
POST /v1/storage/bucket/create takes a region, and it’s enforced rather than decorative. Ask for the region that exists and you get a bucket:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"media-apac","region":"ap-singapore","acl":"private"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_acd36011a271459e902128",
"name": "media-apac",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"created_at": "2026-07-27T00:38:04.827890Z",
"cors_rules": [],
"lifecycle_rules": []
}
}
Ask for a European one and the call fails, and the message names the region you can actually have:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"media-eu","region":"eu-central-1","acl":"private"}'
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"message": "COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable"
}
}
That is the entire residency answer, delivered in one response body at the moment you’d want it — which beats a field that accepts anything and files it away as a label. Whatever a bucket carries, you can read it back:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/media-apac" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
One storage region, in Singapore. If you need a jurisdiction guarantee that survives an auditor or a data-processing agreement, that’s a hard boundary on this platform and the recommendation is straightforward: Cloudflare R2 with its EU jurisdiction restriction, or Amazon S3 in eu-central-1. Buy either one for the regulated slice of your data — they put the location in the contract, and there is no EU bucket here to compare against.
Latency is the other half, and it’s cheaper to fix
Perceived speed and legal placement get conflated constantly, and only one of them needs a second vendor. A US or EU visitor fetching a 4 MB asset from Singapore is paying for distance on every request; put a CDN in front of the signed origin and the second visitor pays for nothing. That works because the file is identical everywhere — which is exactly the case for product images, exports and static media, and exactly not the case for a patient record.
The rule of thumb we’d offer: if the objects are the same for every user, cache them. If the objects belong to a specific user in a specific country, route them.
The routing layer you want either way
Don’t scatter bucket names through nine services. Keep the mapping in one function, keep the region on the user row, and the day you move a slice of traffic elsewhere it’s a config change.
// storage-router.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
// The one place that answers "where do this user's bytes go?".
const ROUTES = {
us: { store: "infrai", bucket: "media-apac" },
row: { store: "infrai", bucket: "media-apac" },
eu: { store: "external", bucket: "media-eu" },
};
export function routeFor(user) {
const route = ROUTES[user.data_region];
if (!route) throw new Error(`no storage route configured for ${user.data_region}`);
return route;
}
export async function uploadSlot(user, objectKey, contentType, maxBytes) {
const route = routeFor(user);
if (route.store !== "infrai") throw new Error(`${objectKey} belongs in the ${user.data_region} store`);
const res = await fetch(`${API}/v1/storage/object/presign/${route.bucket}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 300, content_type: contentType, max_bytes: maxBytes }),
});
if (!res.ok) throw new Error(`presign failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
return { bucket: route.bucket, url: data.url, method: data.method ?? "PUT", headers: data.headers ?? {}, expiresAt: data.expires_at };
}
export async function bytesStored(bucket) {
const res = await fetch(`${API}/v1/storage/bucket/usage/${bucket}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`usage failed: HTTP ${res.status}`);
const { data } = await res.json();
return data.byte_count;
}
Put the region in the key as well as in the bucket — eu/u-4471/invoices/2026-07.pdf — and a misrouted object becomes greppable instead of invisible.
Measuring the distance you’re trading
Latency arguments get settled with a script, not with a map. Run this from a US host and an EU host and compare the medians before anyone commits to a number in a design doc:
#!/usr/bin/env python3
"""Median round-trip for a metadata read, run from wherever your users are."""
import json
import os
import statistics
import time
import urllib.request
API = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
raise SystemExit("set INFRAI_API_KEY first")
URL = f"{API}/v1/storage/bucket/usage/media-apac"
samples = []
for _ in range(20):
req = urllib.request.Request(URL, headers={"Authorization": f"Bearer {KEY}"})
start = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=20) as resp:
json.loads(resp.read())
except Exception as err: # noqa: BLE001 - a failed sample is still a data point
print("sample failed:", err)
continue
samples.append((time.perf_counter() - start) * 1000)
if samples:
print(f"n={len(samples)} median={statistics.median(samples):.0f}ms p95={sorted(samples)[int(len(samples) * 0.95) - 1]:.0f}ms")
Control-plane calls in our own runs landed between 50 ms and 180 ms, though that says more about the machine we ran it on than about your users — which is the point of measuring it yourself.
Weighing the designs
| Design | US/EU latency | Residency guarantee | What you operate |
|---|---|---|---|
| Two S3 buckets + CloudFront | strong, edge-cached | yes, per-region | an AWS account, IAM policies, one SDK |
| Cloudflare R2 with EU jurisdiction | strong, global network | yes, EU-restricted | one account, an S3-compatible SDK |
| Infrai bucket + a CDN in front | one origin, cached at the edge | no — ap-singapore only | one key, plain REST, no SDK |
| Split: regulated data external, rest here | good on both | yes, for the slice that needs it | two stores behind one router |
The bottom row is the trade-off most teams land on once the question gets separated properly. The regulated slice is usually small, the rest of the application is large, and paying a second vendor relationship for the whole of it to satisfy the small part is how stacks get expensive.
What the split costs to operate
Bucket management is free on Infrai, so the routing layer itself costs nothing to run. Verified 27 July 2026: bucket create, GET /v1/storage/bucket/list, GET /v1/storage/bucket/usage/{bucket}, head, list and presign are all free and rate-limited. Only the byte-moving calls meter, and they don’t meter on the same unit.
A write is priced per call — PUT /v1/storage/object/put/{bucket}/{key} sits at $0.0001.
A read is priced by volume — GET /v1/storage/object/get/{bucket}/{key} meters $0.104 per GB of egress, so what a geographically split user base costs you is the bytes it pulls, not the number of times it pulls them. A CDN in front cuts that bill for the same reason it cuts latency. New accounts start with $2 of trial credit. Confirm today’s numbers, since these tend to move downward as vendor rates fall:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; [print(b['key'], b['calls'], b['cost']) for b in d['breakdown'] if b['key'].startswith('storage.')]"
The per-tenant split you actually bill customers on is one free call per bucket:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/media-apac" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns byte_count, object_count and an as_of timestamp — enough for cost attribution per tenant without a reconciliation project across invoices. And the work either side of an upload is already on the same account: POST /v1/queue/publish to hand the file to a worker, POST /v1/cron/create to expire it on schedule, POST /v1/errors/capture when a transfer dies halfway. That’s the real argument for keeping the unregulated majority here — not the storage rate, but the services you’d otherwise be integrating one signup at a time.
Design the router first. Swapping what sits behind it is a config change; swapping a hard-coded bucket name in nine services is a sprint.