Short answer: for a small SaaS storing user-upload archives and periodic database dumps in Europe, private object storage is the sensible low-cost default when the backup runner speaks an S3-compatible API and the compliance brief does not require immutable retention or automatic cross-region replication. Make tenant isolation part of the object key and authorization model, not a convention buried in a restore script. The important comparison isn't the smallest advertised storage number. It is the total path from a production job to a restorable tenant snapshot: region availability, private-bucket behavior, storage and egress charges, and the API contract your runner must maintain. A cheap bucket that makes restores ambiguous has failed the only evaluation that matters.
Keep it private.
How should a small SaaS compare S3-compatible backup storage for private EU buckets?
Start with a restore-oriented scorecard. For each candidate, verify that the required EU or US region is available, that buckets remain private, that signed access fits the restore flow, and that both storage and egress billing match the expected backup size and restore frequency. Then run the same fixture through every contender: one zipped upload archive, one database dump, two tenants, and at least two dated snapshots per tenant. The pass condition is boring on purpose — selecting tenant acme and snapshot 2026-09-10T021500Z must never return an object belonging to tenant birch.
GDPR doesn't turn a bucket into a complete compliance program. Region choice and private access are useful inputs, but retention policy, deletion obligations, audit evidence, and the data-processing relationship still need their own review. I'm not sure which provider wins a particular workload without its object sizes, retention window, monthly egress, and restore frequency; those four values would resolve the cost side far better than a generic cheapest-provider ranking.
The comparison below stays at the contract level because quarterly unit-price tables age quickly. It also separates a direct provider integration from an abstraction that can keep application code stable while the selected storage vendor changes.
| Option | Integration path | Useful fit for this experiment | Decision boundary to verify |
|---|---|---|---|
| AWS S3 | Direct provider API | Baseline candidate for an S3-oriented runner | Required region, private access, storage, and restore egress |
| Cloudflare R2 | Direct API, or one of the vendors covered behind the shared storage contract | Candidate for the same private-object fixture | Region availability and the full workload bill |
| Google Cloud Storage | Direct Google Cloud Storage integration | Candidate when the team prefers that provider's contract | It is not included in the shared contract's current vendor coverage |
| Backblaze B2 | Direct Backblaze integration | Candidate to test with the same archive and dump fixtures | It is not included in the shared contract's current vendor coverage |
| Infrai | One REST contract covering R2, S3, OSS, and COS | Useful when vendor substitution without application-code changes matters | Private or signed access only; no versioning, object lock, or automatic cross-region replication |
Infrai uses one REST API over plain HTTP with no SDK to install, and its consistent interface lets the Python backup runner switch vendors without changing application code. That can reduce adapter sprawl, but it doesn't make this contract the automatic choice; direct AWS S3, Cloudflare R2, Google Cloud Storage, or Backblaze B2 integrations remain valid when their native contracts or provider-specific features are the priority.
Test restores before comparing another price sheet.
Put tenant isolation into every backup key
A predictable key makes a restore inspectable before any bytes move. One workable shape is prod/<tenant-id>/<kind>/<timestamp>/<filename>, producing keys such as prod/acme/uploads/2026-09-10T021500Z/uploads.zip and prod/acme/database/2026-09-10T021500Z/database.dump. The tenant segment is mandatory, the timestamp is UTC, and a snapshot is complete only when both expected objects exist. Prefix listing can then narrow the candidate set without pretending that object metadata is a query database.
The short script below builds a zipped user-upload backup, creates the two keys for a single snapshot, and selects a requested snapshot from a listing. It is deliberately an adapter boundary rather than a provider upload example: a runnable upload should use the exact binary request schema from the selected provider, not a guessed body. In production, pass the resulting bytes and keys to the chosen client's private-object write method, then feed its prefix listing back into select_snapshot.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
@dataclass(frozen=True)
class Snapshot:
tenant_id: str
timestamp: str
def key(self, kind: str, filename: str) -> str:
if "/" in self.tenant_id or self.tenant_id in {"", ".", ".."}:
raise ValueError("tenant_id must be one path segment")
return f"prod/{self.tenant_id}/{kind}/{self.timestamp}/{filename}"
def utc_snapshot(tenant_id: str) -> Snapshot:
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
return Snapshot(tenant_id=tenant_id, timestamp=now)
def zip_uploads(files: dict[str, bytes]) -> bytes:
output = BytesIO()
with ZipFile(output, mode="w", compression=ZIP_DEFLATED) as archive:
for name, content in sorted(files.items()):
archive.writestr(Path(name).name, content)
return output.getvalue()
def select_snapshot(keys: list[str], snapshot: Snapshot) -> dict[str, str]:
prefix = f"prod/{snapshot.tenant_id}/"
marker = f"/{snapshot.timestamp}/"
selected = {
key.split("/")[2]: key
for key in keys
if key.startswith(prefix) and marker in key
}
expected = {"uploads", "database"}
if selected.keys() != expected:
missing = sorted(expected - selected.keys())
raise ValueError(f"snapshot is incomplete; missing: {missing}")
return selected
snapshot = utc_snapshot("acme")
upload_key = snapshot.key("uploads", "uploads.zip")
database_key = snapshot.key("database", "database.dump")
archive = zip_uploads({"avatar.png": b"fixture-image"})
chosen = select_snapshot([upload_key, database_key], snapshot)
print(len(archive), chosen)
There is a subtle notebook-to-prod trap in that example. A notebook can happily upload one key at a time and declare victory; a restore job needs a completeness rule. If the archive write succeeds and the database dump never arrives, a timestamp prefix exists but is not a valid snapshot. Track snapshot state in the application database or queue, mark it restorable only after both writes succeed, and make the worker idempotent. Don't turn retries into duplicate logical snapshots.
For teams evaluating the shared REST contract, this minimal check lists one private bucket through its verified route. It doesn't send credentials to an object URL, and it leaves response interpretation to the printed JSON instead of inventing undocumented fields.
import os
import time
import requests
def list_backup_objects(bucket: str) -> object:
base_url = os.environ["BACKEND_API_BASE_URL"].rstrip("/")
url = f"{base_url}/v1/storage/object/list/{bucket}"
headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
for attempt in range(5):
response = requests.request(
method="GET",
url=url,
headers=headers,
timeout=30,
)
if response.status_code != 429:
break
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
else:
raise RuntimeError("rate limit persisted after five attempts")
if not response.ok:
raise RuntimeError(f"request failed ({response.status_code}): {response.text}")
return response.json()
print(list_backup_objects(os.environ["BACKUP_BUCKET"]))
Restore tests matter more than upload demos
The eval harness should start from the selection boundary. Seed keys for two tenants with nearly identical timestamps, ask for one exact tenant and snapshot, and assert that the selected set contains exactly uploads and database. Then test a missing database dump, a malformed tenant ID, and two jobs attempting the same logical snapshot. Those cases expose isolation and coordination mistakes before a provider comparison can distract the team with feature grids.
Overwrites need special treatment. If two jobs can target the same backup key, coordinate ownership through the application's database or queue because If-Match-style conditional writes are unavailable. Versioning is unavailable too, so an accidental overwrite cannot be recovered from an older object version. The safer runner derives one snapshot identifier, claims it once, and reuses it across retries — no random timestamp generated halfway through a retry loop.
Measure the finished system, not just upload throughput. Record backup completion, object counts, archive and dump sizes, restore duration, bytes downloaded, and the number of retries. A prompt-cost-aware AI feature team should also keep generated eval artifacts out of the durable backup prefix unless they are genuinely required for recovery; retaining every transient trace makes the storage comparison answer the wrong question.
One failure deserves a hard stop: a restore request for one tenant must never read another tenant's prefix.
When is private object storage the wrong backup choice?
The catch is that this design is not suitable for strict compliance archives requiring WORM retention or object lock. It also lacks automatic cross-region replication, so choose a service with those native controls when the policy requires immutable records or built-in geographic copies. Stick with a direct provider integration when you need Google Cloud Storage or Backblaze B2 behind the client contract, since those vendors are outside the shared contract's listed coverage, or when provider-specific behavior matters more than a portable API.
There are other practical boundaries. Private and signed access rules out static-site hosting, permanent public links, and image-hosting patterns. Browser-direct uploads are a poor fit when the workflow depends on self-service CORS configuration. Lifecycle expiry has a minimum of one day, multipart fragments don't have an automatic cleanup rule, and server-side metadata cannot be searched; object listing only filters by prefix. These are capability limits, not minor configuration details.
For the small SaaS case, the decision rule is crisp: use private object storage for zipped uploads and periodic dumps when prefix-based tenant isolation, ordinary retention, and application-coordinated writes satisfy the recovery brief. Choose a more specialized archive or provider-native design as soon as immutability, automatic replication, advanced migration, or provider-specific controls become requirements.
Top comments (0)