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 VPSBackblaze B2Cloudflare R2Infrai storage
Cashserver + disk you may already pay for~$6/TB-month~$0.015/GB-monthGB-month rent + per-call writes
Egressyour provider’s bandwidth billfree up to 3× storednonemetered separately
Durabilitywhatever your disks and RAID give youmulti-datacentremulti-datacentrevendor-backed, multi-tenant
Ops you ownOS, TLS, upgrades, disk, offsite copyaccount adminaccount adminAPI key rotation
Signed linksmc share download, or the S3 SDKS3 presignS3 presignone free REST call
Recovery when it burnsyour problemprovider’sprovider’sprovider’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.

References

Browse more storage developer guides