Self-hosted MinIO or managed object storage? The honest ops math
What a single-node MinIO box really guarantees, where managed buckets win, and a mirror-plus-restore setup that keeps both without doubling the work.
If you already run a server with spare disk, MinIO costs nothing extra in cash and quite a lot in attention: you own the disk failures, the upgrades, the TLS certificate and — the part that bites — the backup of the backup. Managed object storage inverts that. Infrai’s storage namespace is one middle option, a plain REST bucket on the same key as your cron jobs and alerting, which matters when the thing you’re storing is app data that other services need to react to.
Neither is wrong. The decision turns on one question: how many hours a month do you want to spend being a storage administrator?
Two bills, and only one of them arrives by email
| Single-node MinIO on a VPS | Backblaze B2 | Cloudflare R2 | Infrai storage | |
|---|---|---|---|---|
| Cash | server + disk you may already pay for | ~$6/TB-month | ~$0.015/GB-month | GB-month rent + per-call writes |
| Egress | your provider’s bandwidth bill | free up to 3× stored | none | metered separately |
| Durability | whatever your disks and RAID give you | multi-datacentre | multi-datacentre | vendor-backed, multi-tenant |
| Ops you own | OS, TLS, upgrades, disk, offsite copy | account admin | account admin | API key rotation |
| Signed links | mc share download, or the S3 SDK | S3 presign | S3 presign | one free REST call |
| Recovery when it burns | your problem | provider’s | provider’s | provider’s |
Infrai’s rates are live data rather than an article’s memory, and reading them takes one call:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | grep -o '"storage.object[^}]*}' | head -4
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
New accounts get $2 free credit, which is enough to move a few thousand objects and decide for yourself. Per-GB storage pricing across every provider above has fallen steadily, so read the live figure rather than trusting a table — including this one.
What a single MinIO node actually promises
Erasure coding on one machine protects you from a bad drive, not from a bad machine, a bad datacentre, a bad rm -rf, or a filesystem that decides today is the day. That’s not a criticism of MinIO — it’s an accurate reading of what one node can do.
RAID isn’t a backup either.
So the honest self-hosted setup is not “MinIO instead of the cloud”. It’s MinIO plus an offsite copy somewhere you don’t administer, which means you’re paying for managed storage anyway, just less of it. Once you’ve accepted that, the interesting question stops being “which one” and becomes “what does the copy job look like, and have I ever restored from it”.
The hybrid, in two commands and one script
MinIO’s own client handles the local half. mc mirror is incremental and safe to re-run:
mc alias set local http://127.0.0.1:9000 "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}"
mc mirror --overwrite --remove local/app-data /srv/staging/app-data
mc ls --recursive local/app-data | tail -5
The offsite half is a walk over that staging directory, uploading anything whose size doesn’t already match what’s stored. head is free, so the comparison costs nothing:
import { readdir, readFile, stat } from "node:fs/promises";
import { join, relative } from "node:path";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const BUCKET = "offsite-mirror";
const ROOT = process.env.MIRROR_ROOT ?? "/srv/staging/app-data";
const auth = { Authorization: `Bearer ${KEY}` };
async function alreadyStored(key, size) {
const res = await fetch(`${BASE}/v1/storage/object/head/${BUCKET}/${key}`, { method: "GET", headers: auth });
const json = await res.json();
return json.ok === true && json.data.found === true && json.data.size_bytes === size;
}
async function push(key, bytes) {
const payload = { data_base64: bytes.toString("base64"), content_type: "application/octet-stream" };
const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!json.ok) throw new Error(`${key}: ${json.error?.code} ${json.error?.message}`);
return json.data;
}
const day = new Date().toISOString().slice(0, 10);
const entries = await readdir(ROOT, { recursive: true, withFileTypes: true });
let pushed = 0;
let skipped = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
const abs = join(entry.parentPath ?? entry.path, entry.name);
const key = `mirror/${day}/${relative(ROOT, abs)}`;
const info = await stat(abs);
if (info.size > 8 * 1024 * 1024) { console.warn(`skip ${key}: use multipart for ${info.size} bytes`); continue; }
if (await alreadyStored(key, info.size)) { skipped++; continue; }
const written = await push(key, await readFile(abs));
pushed++;
console.log(`${written.key} · ${written.size_bytes} bytes · ${written.etag}`);
}
console.log(`mirror done: ${pushed} uploaded, ${skipped} unchanged`);
That size guard is deliberate. A base64 body works well for small app data and gets slow fast — 9 MiB took 10.4 s in our measurements against 2.5 s for a presigned direct PUT — so anything chunky belongs on the multipart path instead.
Sharing a file from either side
MinIO gives you mc share download --expire 12h local/app-data/report.csv. The managed equivalent is one free call, and it returns the expiry it actually granted:
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/offsite-mirror/mirror/2026-07-26/notes.md" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"download","expires_seconds":3600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.offsite-mirror/mirror/2026-07-26/notes.md?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=486f300442898b",
"expires_at": "2026-07-26T02:13:04Z"
}
}
One difference worth flagging: a MinIO bucket set to private genuinely refuses unsigned reads, while in our testing an Infrai-stored object answered a plain request with the signature stripped. Treat the object key as the secret on this side, keep your API as the authorization check, and don’t hand a signed URL to someone you wouldn’t hand the file to.
The restore path you should rehearse
Listing and head are free, so a drill costs one billable read per file you actually pull back:
curl -sS "https://api.infrai.cc/v1/storage/object/list/offsite-mirror?prefix=mirror/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/offsite-mirror" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"key": "mirror/2026-07-26/notes.md",
"size_bytes": 7,
"etag": "e59784dc2e43771c586c65bded272673",
"last_modified": "2026-07-26T01:01:12Z"
}
],
"next_cursor": null
}
}
Page with next_cursor until it’s null, compare the object count against what the mirror job reported, and you have a check that catches a silently broken cron before an incident does.
So, which
Stick with MinIO if the data is large, mostly local, latency-sensitive, and you already have someone who patches that server anyway — then add an offsite copy and stop pretending the node is a backup. Go with B2 or R2 if the only requirement is bytes at rest for the lowest possible number and you don’t need anything else from the provider.
Choose Infrai when the storage is one part of an application rather than an archive: the same key runs the schedule that triggers this mirror, records the failure when a night goes wrong, and mails you about it, with per-tenant cost attribution as a query instead of four dashboards. The limitation to weigh against that is real — no S3 SDK compatibility, no bucket CORS route, and a bucket’s stated region is metadata rather than a residency guarantee.