Already on DigitalOcean droplets: Spaces, S3, or neither?
Settle the Spaces-versus-S3 split on egress and colocation rather than per-GB rates, with the honest case for each and where a multi-service API fits instead.
Short version: if your droplets are already on DigitalOcean and what you need is put, get, list and presigned links at moderate volume, Spaces is the default and the half of your team arguing for it is right. Same datacentre, one invoice, an S3-compatible API so a later move is a config change rather than a rewrite. Reach for S3 when you need something only S3 has — fine-grained IAM, lifecycle transitions with day boundaries, cross-region replication, or a compliance document with the word AWS in it.
There’s a third answer that neither half of the team is proposing, and it’s only right in one specific situation: if object storage is one of five services you’re about to bolt on this quarter, something like Infrai puts storage, cron, email and error tracking behind a single credential and a single bill. If storage is genuinely the only thing you need, skip that option — a specialist will serve you better and cheaper.
The argument is about egress, not storage
Per-GB storage rates across the market sit close enough together that they rarely decide anything. Egress is where the order-of-magnitude differences live.
DigitalOcean publishes Spaces at $5/month covering 250 GiB of storage and 1 TiB of outbound transfer, with overage at $0.02/GiB stored and $0.01/GiB transferred. S3 Standard is around $0.023/GiB-month, and internet egress runs $0.09/GiB after the free tier. Read those two transfer numbers next to each other — that’s roughly a 9× gap on the byte that leaves the building, and it’s the whole reason image-heavy and download-heavy apps end up on Spaces, R2 or Backblaze.
So the useful question isn’t “which is cheaper per GB”. It’s “how many GB leave per month, and from where”.
Measure it before you argue about it. If your droplets serve 40 GB of outbound object traffic a month, the difference between these vendors is a rounding error and you should pick on operational fit. If they serve 4 TB, egress is the entire decision.
The three options side by side
| DigitalOcean Spaces | Amazon S3 | Infrai storage | |
|---|---|---|---|
| Colocated with DO droplets | yes, same region | no, unless you move compute | no — see the region caveat below |
| Billing shape | flat monthly base plus overage | pure per-GB and per-request | per-call, plus metered GB-month and egress |
| Egress cost profile | a bundled TiB, then a low per-GiB rate | per-GiB after the free tier, an order of magnitude higher | metered, read from the usage API |
| Lifecycle: delete after N days | yes | yes | yes (expire_days) |
| Lifecycle: transition after N days | limited | yes (Transition.Days) | not supported |
| CDN in front | built in | CloudFront, separate | not included |
| Other services on the same key | the rest of DigitalOcean | the rest of AWS | AI, email, cron, queues, metrics |
| S3-compatible API | yes | it is the API | REST, not S3 wire-compatible |
When S3 earns the second invoice
Be honest with the team about what actually justifies leaving the DO bill:
You need IAM policies at object-prefix granularity, per-role, auditable. You need objects to move to a colder class on a day boundary — Spaces has cold storage, but if the schedule matters, S3’s Transition.Days is the thing that expresses it. You need cross-region replication as a contractual durability story. Or the rest of your data platform — Athena, Glue, Lambda triggers, SageMaker — already lives in AWS and the object store is just the front door.
None of those are “S3 is faster” or “S3 is cheaper”, because for this workload it usually isn’t either.
The colocation caveat, stated plainly
If the reason you’re leaning toward Spaces is that the bytes stay inside DigitalOcean’s network, that’s a real advantage and Infrai can’t match it. Region on Infrai is not a placement guarantee. We created a bucket asking for eu-central-1, and the API reported that region back happily:
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":"kb-do-eu-0726","region":"eu-central-1","acl":"private"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_92f376647d874cac9998eb",
"name": "kb-do-eu-0726",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Then we asked for a presigned URL to an object in it, and the host that came back was cos.ap-singapore.myqcloud.com. The field is metadata, not placement. If low latency from your Frankfurt droplets or a data-residency commitment is on the table, that’s a hard limitation and Spaces or S3 is the correct pick — don’t let a field name carry a promise it isn’t making.
Working the Infrai side
Buckets, objects and usage are plain REST. Nothing here needs an SDK:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-spaces-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"byte_count": 12288,
"object_count": 3,
"as_of": "2026-07-26T00:55:33.964369Z"
}
}
And the estimate your finance person actually wants — total bytes across every bucket on the account, which is the input to any GB-month projection:
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function readJson(path) {
const res = await fetch(`${API}${path}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const json = await res.json();
if (!res.ok || json.ok === false) {
const e = json.error ?? {};
throw new Error(`GET ${path} -> HTTP ${res.status} ${e.code ?? ""} ${e.message ?? ""}`);
}
return json.data;
}
const { items } = await readJson("/v1/storage/bucket/list");
let totalBytes = 0;
let totalObjects = 0;
for (const bucket of items) {
const usage = await readJson(`/v1/storage/bucket/usage/${bucket.name}`);
totalBytes += usage.byte_count;
totalObjects += usage.object_count;
console.log(
`${bucket.name.padEnd(28)} ${(usage.byte_count / 1e9).toFixed(3)} GB ${usage.object_count} objects`,
);
}
console.log(`\ntotal: ${(totalBytes / 1e9).toFixed(3)} GB across ${items.length} buckets, ${totalObjects} objects`);
Run that against a week of real traffic and the Spaces-versus-S3 argument usually settles itself, because one side of the room discovers the monthly delta is smaller than the meeting they held about it.
What the calls cost, and how to check
Storage writes are $0.0001 per call and reads $0.0002; bucket creation, listing, usage and presigning are free and rate-limited. Verified 26 July 2026, with $2 of credit on a new account. Stored bytes and egress are metered separately from the call charges, so read your own spend rather than modelling it:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"period": "30d",
"total_cost": 4.21,
"total_calls": 8172,
"breakdown": [
{ "key": "storage.object.put", "label": "storage.object.put", "cost": 0.4229, "calls": 4229 },
{ "key": "storage.object.get", "label": "storage.object.get", "cost": 0.0266, "calls": 11842 }
]
}
}
Rates across this market keep drifting downward and vendors run campaigns, so whatever you read today is likely at or below these figures. The durable point isn’t the rate — it’s that the per-tenant attribution above is a single GET rather than a spreadsheet reconciling three vendors.
How we’d settle the split
Stay on Spaces if the workload is images, backups and user downloads served from DO compute. It’s the boring answer and boring is correct here.
Move that one bucket to S3 if a specific S3 feature is load-bearing — day-boundary tiering, prefix-level IAM, replication — and accept the second invoice for it. That’s a per-bucket decision, not a platform migration, and framing it that way usually ends the argument.
Consider Infrai only when the next three tickets after “add object storage” are “add a nightly job”, “send a notification email” and “capture errors”, because that’s the case where one credential and one bill genuinely beats four accounts. If you need colocation with your droplets or an S3-wire-compatible client library, it isn’t the right tool and Spaces remains the answer.