MinIO on a spare box vs paid object storage, for a small team

A three-engineer TCO check: measure your real footprint, price the on-call hours honestly, and see where self-hosted MinIO wins and where it quietly costs more.

For three engineers, the deciding number is almost never dollars per terabyte. It’s how many hours a month the storage layer takes from the people who were supposed to be shipping the product, and what happens the night the one box holding customer files stops answering. MinIO on a spare server is genuinely good software with a real cost profile; a hosted API like Infrai’s storage surface, S3 or Backblaze B2 shifts that cost from your calendar to your card.

So do the arithmetic properly rather than by instinct. Measure what you actually store, price the operational hours at what your time is worth, and only then compare against a rate card — most small teams who run this calculation honestly discover their data is smaller and their spare capacity flakier than they assumed.

Measure the footprint before you model it

Guessing at capacity is how a spare server becomes a full one. If your bytes already sit in a hosted bucket, the usage call is free and exact:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-tco-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "byte_count": 4096,
    "object_count": 2,
    "as_of": "2026-07-26T00:46:55.689355Z"
  }
}

Across a whole account, walk the bucket list and total it. This is the input to every other decision on the page:

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");
const headers = { Authorization: `Bearer ${KEY}` };

const buckets = await fetch(`${API}/v1/storage/bucket/list`, { method: "GET", headers });
if (!buckets.ok) throw new Error(`bucket list failed: ${buckets.status} ${await buckets.text()}`);

let totalBytes = 0;
let totalObjects = 0;
for (const b of (await buckets.json()).data.items) {
  const res = await fetch(`${API}/v1/storage/bucket/usage/${b.name}`, { method: "GET", headers });
  if (!res.ok) { console.warn(`skip ${b.name}: HTTP ${res.status}`); continue; }
  const u = (await res.json()).data;
  totalBytes += u.byte_count;
  totalObjects += u.object_count;
  console.log(`${b.name.padEnd(28)} ${(u.byte_count / 1e9).toFixed(3)} GB  ${u.object_count} objects`);
}
console.log(`TOTAL ${(totalBytes / 1e12).toFixed(4)} TB across ${totalObjects} objects`);

Two things usually fall out of that run. The total is smaller than the mental model — a document-heavy B2B app often lives under 1 TB for years — and the object count is much larger than expected, which matters because request volume, not capacity, is what makes a single-box deployment sweat.

Price the hours, not just the disks

A 4 TB drive is cheap. The recurring costs are the ones nobody budgets: patching, TLS renewal, capacity alarms, an upgrade that changes a config format, and the backup of the self-hosted store — because a MinIO box is not a backup of itself. Put your own numbers in and let the model argue:

#!/usr/bin/env python3
"""Compare self-hosted MinIO against a hosted per-GB rate, hours included."""
import os

TB = float(os.environ.get("TB_STORED", "1.0"))
HOURS_PER_MONTH = float(os.environ.get("OPS_HOURS", "4"))
HOURLY = float(os.environ.get("ENG_HOURLY", "60"))
HOSTED_PER_TB_MONTH = float(os.environ.get("HOSTED_RATE", "6"))

hardware = 40.0          # amortised box + drives + spare, per month
colo_or_vps = 30.0       # power, bandwidth, or the VPS bill
offsite_backup = TB * HOSTED_PER_TB_MONTH  # you still need a copy elsewhere
people = HOURS_PER_MONTH * HOURLY

self_hosted = hardware + colo_or_vps + offsite_backup + people
hosted = TB * HOSTED_PER_TB_MONTH

print(f"self-hosted MinIO : ${self_hosted:,.2f}/mo  (of which people ${people:,.2f})")
print(f"hosted object API : ${hosted:,.2f}/mo")
print("break-even TB:", round((hardware + colo_or_vps + people) / HOSTED_PER_TB_MONTH, 1))

if self_hosted > hosted * 2:
    print("=> self-hosting is a hobby at this scale, not a saving")

With four hours a month and a modest hourly rate, break-even lands in the tens of terabytes — which is exactly why self-hosting is a real answer for media archives and a bad one for a document store. The catch is that those four hours aren’t evenly spread; they’re zero for five months and a weekend when a drive dies.

What one box actually promises

Erasure coding is MinIO’s durability story, and it wants several drives before it can lose one and keep serving — a single-disk deployment gives you availability, not durability. Add to that: no second site, one power feed, one kernel, one person who knows how it was set up. For a three-person team, that last one is the real risk. Bus factor is a storage property.

None of that makes MinIO wrong. It makes a single spare server the wrong shape for anything you’d have to tell a customer about.

Where each option lands

SituationBetter pickWhy
50 TB of reproducible media, in-house rackMinIO on your own drivesCapacity is the whole bill; you already own the hardware
Air-gapped or on-prem-only deploymentMinIONo hosted option can be inside a closed network
Local dev and CI fixturesMinIO in DockerFree, disposable, S3-compatible for test doubles
Under ~5 TB of customer filesHosted (S3, Backblaze B2, Cloudflare R2, Infrai)Ops hours dominate the rate card
Heavy public egressCloudflare R2Zero-egress pricing beats everything else on this list
Storage is one of six services you needInfraiThe same key also covers queues, cron, email and error capture

For a local MinIO — which is a genuinely good idea regardless of the production choice — this is the whole setup:

docker run -d --name minio \
  -p 9000:9000 -p 9001:9001 \
  -e MINIO_ROOT_USER=minioadmin \
  -e MINIO_ROOT_PASSWORD=minioadmin \
  -v "$HOME/minio-data:/data" \
  quay.io/minio/minio server /data --console-address ":9001"

What paying actually buys a small team

Not disks. It buys not being the storage team. And on a consolidated platform it buys the adjacent jobs too: creating a bucket, expiring old objects, queueing the follow-up work and mailing the user their link are one credential and one invoice rather than four accounts to rotate keys in.

Creating the bucket is one call and free:

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-tco-0726","acl":"private"}'

The honest limitations

Infrai’s storage isn’t an S3-compatible endpoint you can point mc or rclone at — there are no S3 access keys to hand out, so tooling built around the S3 protocol doesn’t apply and you’d be better off on MinIO, S3 or R2 if that ecosystem is the reason you’re asking. There’s no object versioning and no object lock. Bucket ACL supports private and signed-only only. PUT /v1/storage/object/put/{bucket}/{key} carries bytes as base64 inside JSON, which is fine for a CSV and wrong for a 2 GB video — presign or multipart for those. And the region field on a bucket is recorded but not a placement guarantee: in our testing a bucket created as eu-central-1 issued signed URLs on an ap-singapore host, so if residency is contractual, verify the hostname before you promise anything.

Rates, and reading today’s

Structure first, because it survives price changes: bucket create, list, usage, lifecycle, presign and head are free and rate-limited; object reads and writes are billable per call. Reads run about twice writes — verified 26 July 2026, writes were $0.0001 per call and reads $0.0002. Backblaze B2’s published rate for stored data at the same date was $6 per TB per month, which is the figure the model above defaults to. Object storage rates have moved in one direction for a decade, so check rather than quote:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd', 0)) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"

New accounts get $2 in trial credit, which is enough to migrate a test corpus and see the shape of the bill before committing. If after all this the answer is still MinIO — bulk data, hardware you own, someone who enjoys it — that’s a legitimate answer, and it’s the one those TCO comparisons rarely say out loud.

References

Browse more storage developer guides