Serving US and EU users from one storage account, honestly
A bucket-per-region layout for a split user base, the routing code that picks the right one, and where Infrai's region field stops being a placement guarantee.
Design it as two buckets and one routing function, not as two clouds. A user row carries a data_region column, a five-line helper turns that into a bucket name, and every upload and download path in your app goes through that helper — so the storage layout is data, not branching logic scattered across services. Infrai will give you the single account and the single REST surface for that; whether it gives you genuine regional placement is a question you should test before you commit, and this page shows you how.
Start with the sharper question, because the two halves of “low latency and no juggling” pull in different directions. Are you solving perceived speed for a user waiting on a 4 MB PDF, or are you solving a residency requirement where an EU customer’s bytes must not sit on US soil? The first is a caching problem. The second is a placement problem, and only one of them can be fixed with a CDN.
Two buckets, created in one place
POST /v1/storage/bucket/create takes a canonical region code. The accepted set includes us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1, ap-northeast-1, cn-beijing and a few more; anything outside it comes back as STORAGE_INVALID_REGION rather than being quietly coerced, which is the behaviour you want.
export INFRAI_API_KEY="your_infrai_api_key"
for pair in "media-us:us-east-1" "media-eu:eu-central-1"; do
name="${pair%%:*}"
region="${pair##*:}"
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\":\"${name}\",\"region\":\"${region}\",\"acl\":\"private\"}"
echo
done
{
"ok": true,
"data": {
"bucket_id": "bkt_acd36011a271459e902128",
"name": "media-eu",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"created_at": "2026-07-26T00:38:04.827890Z",
"cors_rules": [],
"lifecycle_rules": []
}
}
One account, one key, two regions on paper. Now verify the paper.
The test that matters, and what it told us
A presigned URL is the only place the platform has to reveal where the bytes really live, because the browser or worker connects to that host directly. Ask for one in each bucket and compare the origins.
for bucket in media-us media-eu; do
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/${bucket}/probe.txt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":60}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'].split('?')[0])"
done
In our testing on 26 July 2026, both URLs came back pointing at the same origin host in Asia-Pacific, whatever region code the bucket carried. So the region field is currently metadata that round-trips faithfully through GET /v1/storage/bucket/get/{bucket} — it is not, today, a placement guarantee.
That’s a real limitation and it decides the recommendation. If you have a contractual or regulatory residency obligation for EU personal data, you’d be better off with S3 buckets in eu-central-1 and us-east-1, or with Cloudflare R2’s EU jurisdiction restriction, both of which put the guarantee in writing. Run the probe above against your own account before believing anything else — including this paragraph.
The routing layer you want either way
Here’s the thing worth building regardless of which vendor ends up holding the bytes: the indirection. Keep the mapping in one function, keep the region on the user, and never let a bucket name appear inline in a route handler.
// 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");
const BUCKETS = { us: "media-us", eu: "media-eu" };
export function bucketFor(user) {
const bucket = BUCKETS[user.data_region];
if (!bucket) throw new Error(`no bucket configured for region ${user.data_region}`);
return bucket;
}
export async function uploadSlot(user, objectKey, contentType, maxBytes) {
const bucket = bucketFor(user);
const payload = { op: "put", expires_seconds: 300, content_type: contentType, max_bytes: maxBytes };
const res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`presign failed: HTTP ${res.status} ${await res.text()}`);
const { data } = await res.json();
return { 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 stray object in the wrong bucket becomes greppable instead of invisible.
Measuring instead of guessing
Latency arguments get settled with a script, not with a region map. Run this from a US host and an EU host, both against the same object, and compare the medians:
#!/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-eu"
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, but that number says more about the machine we ran it on than about your users, which is the point of measuring it yourself.
Weighing the four designs
| Design | US/EU latency | Residency guarantee | Accounts and SDKs to run |
|---|---|---|---|
| Two S3 buckets + CloudFront | strong, edge-cached | yes, per-region | one AWS account, IAM policies, one SDK |
| Cloudflare R2 + EU jurisdiction | strong, global network | yes, EU-restricted | one account, S3-compatible SDK |
| GCS dual-region bucket | strong within the pair | yes, within the pair | one GCP project, one SDK |
| Infrai bucket-per-region | one origin today, CDN in front | not today — region is a label | one key, plain REST, no SDK |
The bottom row is the trade-off in one line: you give up the placement guarantee and you get rid of a second vendor relationship. For a startup where the “EU users” are a support-ticket concern rather than a DPA obligation, that’s often the right trade. For a health-tech company with a signed data-processing agreement, it isn’t, and you should stick with the specialist for that one workload.
What the split costs to operate
Two buckets don’t cost twice as much, because bucket management on Infrai is free. Verified 26 July 2026: bucket create, GET /v1/storage/bucket/list, GET /v1/storage/bucket/usage/{bucket}, head, list and presign are all free (rate-limited), and only the byte-moving calls meter — PUT /v1/storage/object/put/{bucket}/{key} at $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} at $0.0002. 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.')]"
And the per-region split you actually bill customers on is one call per bucket:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/media-eu" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns byte_count, object_count and an as_of timestamp — enough to attribute storage to a region, and by extension to a tenant, without a reconciliation project across two vendor invoices. The same key also reaches the queue that processes those uploads and the cron job that expires them, which is the honest reason to consolidate: not the storage rate, but the four other services you’d otherwise be integrating separately.
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.