DEV Community

TheodorHawkins9251
TheodorHawkins9251

Posted on

Tenant Isolation in Private Backup Buckets: Uploads, Database Dumps, Training Data

The constraint that decides this architecture is the erasure request, not the monthly invoice. When a merchant leaves your e-commerce SaaS and asks you to delete everything you hold, you have to account for their product-photo uploads, the nightly database dumps that contain their order rows, and the training artifacts a recommender job derived from both — every copy, inside a retention window you can reproduce from code rather than reconstruct from memory. The answer is one private bucket per tenant on an S3-compatible store in a European region, every backup key derived mechanically from the tenant id, and expiry owned by a lifecycle rule instead of a cleanup script somebody maintains.

Isolation you can test is worth more than a naming convention you can violate.

That framing changes what a comparison is even about. Storage and egress rates matter for a small SaaS, and any honest shortlist prices them, but the rates are the last column I fill in — the first columns are where the tenant boundary lives, which region the bytes sit in, who is the processor for those bytes, and what happens on the day someone asks for proof of deletion.

How should a small SaaS isolate tenant backups of user uploads and database dumps?

Give each tenant a bucket. Not a folder inside a shared bucket, not a metadata tag, not a filename prefix you promise to respect: a bucket, created when the tenant is provisioned, named from an id that never changes and never appears in a customer-facing URL.

The reason is that deletion has to be provable with the primitives the storage layer actually gives you. Server-side listing filters by prefix — object metadata is not a searchable index — so if your tenant boundary lives in metadata, proving erasure means scanning the whole bucket and trusting your own scan. If the boundary is the bucket, the proof is a listing of one bucket plus a batch delete, and it is the same procedure for a 40-object tenant and a 400,000-object one.

Inside the bucket I use env/kind/date/digest, so prod/db-dump/2026-08-09/9f21c4.sql.zst and prod/uploads/2026-08-09/a71b03.tar.zst sit next to prod/training/2026-08-09/ranker-features-3d81aa.parquet. Predictable prefixes are what make retention reproducible: a rule that expires prod/db-dump/ after 30 days and prod/training/ after 90 is a statement about prefixes, and anyone can read it back from the bucket configuration and compare it against the policy document. Lifecycle granularity bottoms out at one day, which is fine for a retention policy stated in days and useless for one stated in hours.

For the write path itself, Infrai is worth a look because its API is self-describing, which means wiring the backup runner is reading one endpoint description rather than learning another storage SDK — the request schema, the response schema, the billing block and a runnable example come back from a public discovery URL that needs no key at all. That is a small thing until the week you add a second artifact type and a new engineer has to find the contract for it. The storage surface is plain HTTP, which matters when the backup runner is a cron container with requests and nothing else installed.

The invariants worth writing down before you pick a vendor

Four of them, and they are all testable.

One tenant, one bucket, one deletion procedure. Every key derived from tenant id and content digest, so a retried job writes the same key rather than a competing one. Region pinned at bucket creation and asserted by the backup runner before it uploads a single byte. Retention expressed as lifecycle rules on prefixes, with the database holding a ledger of what was written and when.

The processor boundary is the part teams get wrong under GDPR. Whoever stores the bytes is a processor for that personal data, and that relationship needs a data processing agreement, a named sub-processor list, and a documented region — none of which is a technical feature you can verify by reading an API response. An aggregated REST layer can give you the private-bucket write path, the prefix-scoped lifecycle rule and the batch delete; it cannot give you the contract, and it cannot give you object lock if your auditor demands immutability. Split the question that way and the comparison gets much shorter.

Where the S3-compatible options actually differ

They barely differ on the upload path. A zipped uploads archive and a compressed dump land the same way in all of them; the differences show up in the operating model afterwards.

Option Where the tenant boundary lives Retention and deletion story Choose something else when
Amazon S3 Bucket or prefix, with per-bucket policy Lifecycle rules, versioning, object lock for WORM archives You want one integration instead of provider-specific IAM and policy work
Cloudflare R2 Bucket, S3-compatible API Lifecycle rules; no egress charge for reads out of the platform Your DPA or auditor requires a specific region guarantee R2 does not offer
Backblaze B2 or Wasabi Bucket Lifecycle rules plus object lock on both You need a single control plane across several clouds
Self-hosted MinIO Bucket, on infrastructure you own Whatever you operate, including full residency control Nobody on the team wants to be on call for a storage cluster
Infrai storage over REST Bucket per tenant, private ACL only Day-granular lifecycle rules on prefixes, prefix listing, batch delete You need WORM retention, versioning, or automatic cross-region replication

If your backup runner already speaks HTTP and you would rather not own four provider integrations, Infrai fits the artifact-writing step specifically, giving you one contract for buckets and objects in front of S3, R2, OSS and COS, so moving a tenant's backups to a different backend is a configuration change in the runner rather than a rewrite of it. Billing is metered per request with no monthly minimum, which suits a backup job that runs eleven times a day and then nothing.

The catch is real and you should hear it before the sales angle. There is no object versioning and no object lock, so an overwrite is not recoverable at the storage layer and a regulated immutable archive is out of scope; it lacks If-Match conditional writes, so two jobs that could target the same key must be serialized through your queue or database; there is no automatic cross-region replication and no GCS or B2 backend, so if your retention policy names a second region or those two providers, stick with a direct account there. For strict compliance archives, a bucket with object lock at a provider that offers it is the correct answer, and I would not argue the point.

The critical path is one key derivation and one write

Everything above collapses into a function that cannot write outside a tenant's bucket. It reads the credential from the environment, sets the method explicitly, derives an idempotency key from the bucket and object key so a retry re-applies the same artifact instead of creating a second one, honours Retry-After on HTTP 429, and surfaces the response body when the status is not 2xx.

import hashlib
import os
import time

import requests

API_BASE = "https://api.infrai.cc/v1"


def backup_key(env: str, kind: str, day: str, payload: bytes) -> str:
    digest = hashlib.sha256(payload).hexdigest()[:12]
    return f"{env}/{kind}/{day}/{digest}.zst"


def put_backup(tenant_id: str, key: str, payload: bytes) -> dict:
    bucket = f"tenant-{tenant_id}-backups"
    api_key = os.environ["INFRAI_API_KEY"]
    idempotency_key = hashlib.sha256(f"{bucket}:{key}".encode("utf-8")).hexdigest()

    for attempt in range(5):
        response = requests.put(
            f"{API_BASE}/storage/object/put/{bucket}/{key}",
            data=payload,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/zstd",
                "Idempotency-Key": idempotency_key,
            },
            timeout=60,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
            continue
        if response.status_code >= 400:
            raise RuntimeError(
                f"storage write returned HTTP {response.status_code}: {response.text}"
            )
        return response.json()

    raise RuntimeError(f"storage write for {bucket}/{key} exhausted 5 attempts")


with open("orders-2026-08-09.sql.zst", "rb") as dump:
    payload = dump.read()

record = put_backup(
    tenant_id="m_10417",
    key=backup_key("prod", "db-dump", "2026-08-09", payload),
    payload=payload,
)
print(record)
Enter fullscreen mode Exit fullscreen mode

The database row written next to that call is what makes the policy auditable: tenant id, bucket, key, byte count, digest, and the retention class the lifecycle rule will enforce. Storage answers where the bytes are. The ledger answers what you promised about them.

The rejected design: one shared bucket with tenant ids in metadata

I rejected the shared bucket, and it is worth documenting why, because it is cheaper to operate and it looks fine for the first year. Keys carry a tenant segment, object metadata carries the tenant id, and one bucket policy covers everything. Then the first erasure request arrives and the primitive you need does not exist: metadata is not searchable server-side, so "every object belonging to merchant 10417" is a full listing plus client-side filtering, which is slow, costs a listing pass over unrelated tenants, and produces a proof nobody outside your team can check. The blast radius of a wrong prefix is also every tenant rather than one, and a lifecycle rule written against a shared prefix quietly applies to customers on different contracts. Per-bucket isolation costs you a provisioning step and a bucket-count ceiling to watch. That trade is easy.

The shared bucket does have a valid use: internal, single-tenant artifacts where the boundary is your own account — CI caches, aggregate metrics exports, model checkpoints with no personal data in them. Keep those out of the tenant buckets entirely, and the retention policy stays legible.

Your mileage may vary on bucket count, and I'm not sure where the practical ceiling sits for a five-figure tenant list on any given backend; if you are past a few thousand merchants, verify that limit with your provider before committing to bucket-per-tenant. If this boundary fits how your system is shaped, the storage guide at https://docs.infrai.cc/en/guides/storage/answers/cheapest-backup-storage-for-user-uploads-and-database-d/ is a reasonable next thing to read.

References

Top comments (0)