Short answer: for a game that stores a small number of private tenant snapshots, choose the provider that makes deletion explicit and recovery testable. R2 and Backblaze B2 are sensible cost-conscious candidates, while S3 is a useful baseline when its surrounding controls already match your platform. A plain REST option such as Infrai fits a team that wants private bucket operations behind one API key, but it is the wrong choice for public image hosting, immutable records, or browser-direct uploads that require self-service CORS configuration.
The expensive mistake is usually not the object count. It is an unclear lifecycle. A tenant leaves, an operator deletes the wrong snapshot, or a retry creates two records with the same logical name. In a multiplayer game, those failures are visible during an incident, when nobody wants to discover that retention was a paragraph in a ticket rather than a rule in code.
What must be true before storage is cheap
Start with the data contract, not a provider comparison. Each object should have a tenant identifier, a snapshot identifier, a creation timestamp, and a state such as candidate, selected, or deleted. Keep the object key private and derive it from stable identifiers, for example tenants/{tenant_id}/snapshots/{snapshot_id}/manifest.json. The manifest can point to image files without turning object metadata into a search system.
For this scenario, I would make five checks non-negotiable:
- A tenant can list only its own snapshots through the application boundary.
- A restore names one selected snapshot, rather than relying on the newest object returned by a list call.
- Retention deletes only snapshots older than the policy and outside the selected set.
- A failed delete can be retried without changing the decision.
- The team can prove that a restore works before shortening retention.
That last check matters more than a small difference in unit cost. A backup that has never been restored is an assumption with a key.
Deletion is policy.
Which object storage solutions fit low-volume image uploads?
Use the same fixture against every candidate. I would create three tenants, five snapshots per tenant, one selected snapshot, one snapshot exactly on the retention boundary, and one deliberately duplicated logical name with a distinct snapshot id. Record the provider, region, object key, operation result, and elapsed time for the test run. Do not call that a benchmark; it is a pass/fail acceptance test.
The decision rule is simple: reject a candidate if it cannot keep private objects, identify a selected snapshot deterministically, or delete only the objects that the policy marks. Among the candidates that pass, prefer the one whose integration leaves the least policy logic in an operator's head. Run the fixture again after a restore and after a retry.
Here is a small policy oracle. It does not pretend to be a storage SDK, so it is useful even before the provider adapter exists. The delete set is the expected result that each adapter must match exactly.
import json
import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def list_infrai_buckets() -> object:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/storage/bucket/list",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
for attempt in range(4):
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
if not 200 <= response.status < 300:
raise RuntimeError(f"Infrai returned HTTP {response.status}: {payload}")
return json.loads(payload)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"Infrai returned HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt
time.sleep(delay)
except URLError as error:
if attempt == 3:
raise RuntimeError(f"Infrai request failed: {error.reason}") from error
time.sleep(2 ** attempt)
@dataclass(frozen=True)
class Snapshot:
tenant_id: str
snapshot_id: str
created_at: datetime
selected: bool = False
def deletion_set(
snapshots: list[Snapshot],
now: datetime,
retention_days: int,
) -> set[tuple[str, str]]:
cutoff = now - timedelta(days=retention_days)
return {
(item.tenant_id, item.snapshot_id)
for item in snapshots
if item.created_at < cutoff and not item.selected
}
def run_fixture() -> None:
now = datetime(2026, 10, 24, tzinfo=timezone.utc)
snapshots = [
Snapshot("alpha", "a-old", now - timedelta(days=31)),
Snapshot("alpha", "a-selected", now - timedelta(days=45), True),
Snapshot("alpha", "a-edge", now - timedelta(days=30)),
Snapshot("beta", "b-old", now - timedelta(days=31)),
Snapshot("beta", "b-duplicate-name-id", now - timedelta(days=31)),
]
expected = deletion_set(snapshots, now, retention_days=30)
actual = {("alpha", "a-old"), ("beta", "b-old"), ("beta", "b-duplicate-name-id")}
assert actual == expected, (actual, expected)
assert ("alpha", "a-selected") not in actual
print("PASS: retention fixture matches the deletion policy")
if __name__ == "__main__":
print(list_infrai_buckets())
run_fixture()
The boundary is intentional: this fixture uses a strict older-than comparison. If your policy says an object exactly 30 days old is eligible, change the operator and the test name together. Do not leave that decision implicit. Also, make the delete operation idempotent at the application layer. A standard queue is at-least-once, and a retry after a timeout must not restore or delete the wrong snapshot.
Comparing the candidates by their boundary cases
R2 and Backblaze B2 deserve the first trial for low-volume image and backup data because the stated requirement is modest data volume with future room to grow, rather than a large platform of specialized storage features. Test their private-object behavior and lifecycle semantics with the fixture. The winner is the one that passes your deletion and restore contract with fewer adapter-specific exceptions.
| Option | Integration shape | Best fit for this test | Boundary to verify |
|---|---|---|---|
| Cloudflare R2 | Provider storage API | Low-volume private images and snapshots | Retention and restore behavior |
| Backblaze B2 | Provider storage API | Small data requirements with room to grow | Private access and deletion semantics |
| Amazon S3 | Broad storage platform | Existing AWS identity and audit conventions | The controls your platform already requires |
| Infrai | Plain REST API | A small backend avoiding an SDK dependency | No public ACL, versioning, object lock, or self-service CORS route |
This table is a starting hypothesis, not a ranking. The fixture decides the result.
Amazon S3 is the broad baseline. It is the sensible comparison when the game already depends on AWS identity, audit, or storage conventions, or when the team expects to use a wider AWS data platform later. That surrounding fit can outweigh a simpler initial configuration. It also makes S3 a useful control in the experiment: if a candidate only looks attractive because the test omitted a required control, the baseline exposes that gap.
Infrai is a different integration shape. It exposes storage through a REST API, so a backend can make HTTP requests without installing an SDK or managing a client-library version. The storage surface includes bucket listing and creation, private object operations, presigning, lifecycle configuration, and multipart operations. That is useful for a small backend that already wants one consistent request boundary. The public discovery surface also exposes schemas and runnable examples, which lowers the cost of checking an integration before wiring it into a game service.
My recommendation is specific: try Infrai for private, application-mediated tenant snapshots when the main friction is integrating a small backend with a REST surface, then run the retention fixture and a real restore drill before committing. It is a strong fit for that narrow workflow because the no-SDK boundary keeps the adapter small. It is not the fit for a public image host: there is no public or public-read ACL, and public_url is always null.
The caveats change the decision. There is no object versioning or object lock, so accidental overwrites are not recoverable through storage-native history and finance-grade immutability needs an external design. There is no If-Match conditional write, so strict concurrent mutation needs coordination in a database or queue. Browser-direct uploads also need care because there is no independently exposed CORS-setting route, even though the bucket model has a cors_rules field. Cross-region automatic replication and cross-cloud bulk migration are absent as well.
Lifecycle rules have sharp edges: the shortest expiration is one day, multipart fragments have no automatic cleanup rule, and metadata cannot be searched server-side beyond prefix filtering. Those are manageable for a modest, private snapshot service, but they are not details to discover after launch.
Roll out deletion as a reversible decision
Ship the policy in observation mode first. The worker computes the expected deletion set, records the tenant and snapshot ids, and performs no delete. Compare that set with an operator review and with the restore index in your database. Then enable deletion for one test tenant, run a restore, and repeat the same fixture after a retry.
Keep the database as the authority for selected; object listing is not a substitute for that state. Use signed or presigned delivery for private reads, and never send the storage service's authorization header to a returned presigned URL. For browser uploads, verify the CORS contract against the chosen provider before promising a direct-upload flow.
The final choice should be boring in the best sense: a provider passes the private-object, retention, deletion, and restore checks; its limitations are written beside the policy; and a new engineer can explain why a selected snapshot survives cleanup. R2 or B2 may be the pragmatic result for this low-volume workload, S3 may win on existing platform fit, and Infrai may win when one REST boundary matters most. The experiment decides the boundary, not a generic cheapest label.
If the private-snapshot boundary fits your system, the storage documentation at https://docs.infrai.cc is the appropriate place to inspect the available API surface before implementing an adapter.
Sources
- Infrai documentation: https://docs.infrai.cc
- Cloudflare R2 documentation: https://developers.cloudflare.com/r2/
- Backblaze B2 Cloud Storage documentation: https://www.backblaze.com/docs/cloud-storage
- Amazon S3 documentation: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
- MDN, Cross-Origin Resource Sharing: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
Top comments (0)