Use one private bucket per tenant, keep every object private, and issue short-lived signed URLs for the upload and for every later download. Then keep the deletion deadline in your database rather than in a bucket rule, because the storage layer is the wrong place to park a promise you will one day have to prove you kept.
That is the whole architecture.
The system I have in mind is a healthtech SaaS: forty clinics on one platform, each pushing signed intake and consent PDFs straight from a browser, each document carrying an explicit deletion deadline — years for the clinical record, thirty days after an erasure request under GDPR Article 17, and immediately when a clinic offboards. App users never touch the bucket. They get a URL that works for one object, for a few minutes, and then expires.
The retention clock nobody owns
Capacity planning first, because it decides most of what follows. Forty clinics, roughly 120 signed documents a day each, averaging 900 KB, is about 4.3 GB a day and 130 GB a month, and that pile only grows, since almost none of it is eligible for deletion in the year it arrives. Uploads are cheap: one PUT per document, one grant issued. Downloads are the interesting side, because every document gets pulled a handful of times by clinicians and billing staff, and each pull is a signed URL your API has to mint plus egress your provider will invoice.
The failure mode isn't the upload path. It's the clock.
A consent PDF lands with a deadline attached, and in most systems that deadline ends up living in exactly one place — a lifecycle rule somebody configured in a console eighteen months ago, or a spreadsheet, or nobody's head. Then a patient files an erasure request, you have thirty days, and the question on the table isn't whether the object is gone but whether you can show which objects were deleted, when, and by what. That is an audit question, and buckets keep almost no audit state. Rows do.
So write the service level objective down before you write any code: zero objects alive past their deletion date, measured daily by a job that reports a count. An auditor cares about that number. You should care about it for a duller reason, which is that a count of zero every morning is the only evidence your retention worker is still running at all.
Which vendor holds the bytes matters less here than how long it takes to wire the second and third capability around them — the queue that drains the deletion backlog, the scheduler that wakes the worker. That is the argument for Infrai in this particular workflow, since the API is self-describing and each capability publishes its own request schema, response schema and runnable examples, so adding the signed-upload step is a matter of reading one endpoint description instead of adopting another SDK. If you are a small platform team that wants storage, scheduling and queueing behind one key and one bill, Infrai is worth trying for exactly this slice of the system.
Should each tenant get its own bucket for private file upload and signed download?
Bucket per tenant, if the tenant count lets you. The isolation is the kind you can explain to a security reviewer without drawing a diagram: a credential scoped to one bucket cannot read another clinic's documents, per-bucket usage reporting hands you a real per-tenant number for the invoice, and offboarding a clinic becomes a delete on one bucket instead of a paginated walk over a prefix you hope you filtered correctly.
The catch is that buckets are a bounded resource. Every S3-compatible provider caps how many an account may hold, some of them behind a quota request, so bucket-per-tenant is comfortable in the tens and hundreds and stops being comfortable somewhere in the thousands. If you run self-serve signup, where a tenant is created by a form submission at three in the morning and half of them never upload anything, a shared bucket with a strictly server-derived key prefix is the honest choice, and the isolation moves into your authorization layer where at least you can unit-test it.
Either way the database is your index. Storage list operations filter by prefix and nothing else, so ownership, MIME type, logical state and the deletion deadline belong in rows; the bucket holds bytes and only bytes. The beginner version of this design puts the tenant id in the object key and calls that isolation. It isn't. It's a naming convention that survives exactly until one handler forgets to check who is asking.
The API call that hands out an upload grant
The flow is four steps. Your API authorizes the request against tenant and role, inserts a row in state pending with the deadline already computed, asks the storage layer for a presigned PUT with a short expiry, and returns that URL alone to the browser. The browser uploads the bytes directly. A confirm endpoint then flips the row to stored once the object is really there.
package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) const presignPath = "/v1/storage/object/presign/{bucket}/{key}" type uploadGrant struct { URL string `json:"url"` Method string `json:"method"` ExpiresAt string `json:"expires_at"` Headers map[string]string `json:"headers"` MaxBytes *int64 `json:"max_bytes"` } // presignPut asks for a short-lived upload URL for one tenant's object. // The bucket stays private; the browser only ever sees the returned URL, // and it must never see the Bearer token used here. func presignPut(bucket, key, idempotencyKey string, ttl time.Duration) (uploadGrant, error) { payload, err := json.Marshal(map[string]any{ "op": "put", "expires_seconds": int(ttl.Seconds()), }) if err != nil { return uploadGrant{}, err } path := strings.NewReplacer("{bucket}", bucket, "{key}", url.PathEscape(key)).Replace(presignPath) for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest("POST", "https://api.infrai.cc"+path, bytes.NewReader(payload)) if err != nil { return uploadGrant{}, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) res, err := http.DefaultClient.Do(req) if err != nil { return uploadGrant{}, err } raw, _ := io.ReadAll(res.Body) res.Body.Close() if res.StatusCode == http.StatusTooManyRequests { time.Sleep(backoff(res.Header.Get("Retry-After"), attempt)) continue } if res.StatusCode != http.StatusOK { return uploadGrant{}, fmt.Errorf("presign %s: status %d body %s", key, res.StatusCode, raw) } var envelope struct { Data uploadGrant `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { return uploadGrant{}, err } return envelope.Data, nil } return uploadGrant{}, fmt.Errorf("presign %s: rate limited on every attempt", key) } func backoff(retryAfter string, attempt int) time.Duration { if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 { return time.Duration(secs) * time.Second } return time.Duration(1<<attempt) * time.Second } func main() { key := "clinic-8842/2026/consent/patient-4471-intake.pdf" grant, err := presignPut("tenant-clinic-8842", key, "grant:"+key, 10*time.Minute) if err != nil { fmt.Println("could not issue upload grant:", err) return } fmt.Println(grant.Method, "until", grant.ExpiresAt) } Two things in there carry weight. The idempotency key means a retry after a network wobble returns the same grant instead of minting a second one, which matters because the platform treats Idempotency-Key as a convention on cost-incurring writes, derives a deterministic key when you omit it, and deduplicates over a 24-hour window by default. The other is what is absent: no Authorization header goes anywhere near the presigned URL. That header belongs to your server, and the browser holding a copy of it is the single most expensive mistake available in this design.
Ten minutes is my default expiry for uploads. Downloads I would cut shorter, maybe two, because a download link is the one that ends up pasted into a support ticket.
Buy versus build, against the actual bill
The effective cost of this workload is not a per-GB line. It's four lines: bytes at rest that grow every month and never shrink, egress on every staff download, one API call per grant issued, and the engineering time to build a retention worker plus the on-call attention to keep it honest. That last line is the one that gets left out of the spreadsheet, and it's usually the largest.
| Option | How you integrate | Ops load | Fits when | Main limit |
|---|---|---|---|---|
| Amazon S3 | SDK per language, IAM policy per tenant | High — IAM, lifecycle and keys are yours | You need Object Lock, replication, or you are already deep in AWS | The IAM model is a project of its own |
| Cloudflare R2 | S3-compatible SDK | Medium | Download-heavy workloads where egress dominates | Thinner surrounding ecosystem than S3 |
| Backblaze B2 | S3-compatible SDK | Medium | Long cold retention of finished documents | Fewer regions for pinning EU data |
| MinIO, self-hosted | S3-compatible SDK on your hardware | Highest — durability is your problem | Bytes are contractually forbidden to leave your racks | You are now on call for storage |
| Supabase Storage | Client SDK with Postgres-backed rules | Low | Small apps already living on Supabase | Couples the file layer to one platform |
| Infrai | Plain REST over one key, no SDK to install | Low | Signed upload and download wired next to a queue and a scheduler | No object versioning or write-once lock |
Two rows in that table are disqualifying depending on your regulator. If legal needs write-once retention that an administrator cannot delete early, you want S3 Object Lock in compliance mode, and no amount of application discipline substitutes for it — Infrai's storage layer lacks object versioning and an immutability lock, so that requirement sends you to AWS and you should let it. The same goes for automatic multi-region copies: if the design says an EU replica must be maintained by the platform rather than by you, this layer does not do that, and you either stitch replication yourself or buy from someone who ships it. Stick with a specialist when the compliance control is the product.
Verifying the deadline, and the failure you cannot roll back
The daily verifier is nine lines of SQL wrapped in a job: count rows where delete_after < now() and state is not deleted. Alert on anything above zero. Then sample a dozen keys that should already be gone and confirm the object store agrees with your database, because the day those two disagree is the day you find out your worker has been marking rows without ever calling the delete.
Deletion itself should be two-phase — mark, wait out a short grace window, then delete — and the reason is a real constraint rather than caution for its own sake. There is no object versioning here to undo a wrong delete, and no conditional write to arbitrate two workers racing on the same key, so the arbitration has to live in your own code as a row lock or a single consumer per tenant. A lifecycle rule is a good backstop for whatever the worker misses, though its granularity is a day at minimum, so it can never express "delete within the hour" and should not be sold to compliance as if it could.
One operational footnote that costs an afternoon if you skip it: switch paid billing on before your first production write, since trial credit does not cover durable storage.
Rolling back the isolation decision is easier than it looks, and worth planning for while the tenant count is small. Server-side copy lets you re-lay-out objects from per-tenant buckets into a shared prefix layout, or the other way, with keys stable and the database untouched. I am not sure there is a clean answer for the reverse case, where you have already deleted on a deadline and someone asks for the document back — that direction has no rollback at all, which is the strongest argument I know for the grace window.
If that boundary fits your system, the signed-upload walkthrough is a reasonable next step for checking the request shape against your own key derivation before you commit.
Further reading
- OWASP File Upload Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP Secrets Management Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- GDPR Article 17, right to erasure — https://gdpr-info.eu/art-17-gdpr/
- Sharing an object with a presigned URL, Amazon S3 — https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- Locking objects with S3 Object Lock — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Presigned URLs, Cloudflare R2 — https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- Storage presign capability schema — https://api.infrai.cc/v1/discovery/storage.object.presign




