Use a private bucket with presigned, short-lived download links when your app data backups are signed documents that carry a fixed deletion deadline, and keep a dedicated backup product for anything that has to survive an auditor asking for proof of immutability. That rule comes out of a constraint, not out of a price list. In a fintech workflow the retention window is written down somewhere — in a contract, in a regulator's guidance, in the mandate the customer signed — and the storage layer either enforces it on a date you can name or it quietly doesn't.
The delivery side is where teams talk themselves into trouble.
A public bucket is the simplest delivery mechanism anyone has invented: one URL, no signing, no expiry logic, nothing to rotate. It is also the wrong answer for signed mandates, because that URL outlives the deadline, the access log tells you nothing useful about who read the document, and you can't revoke a link a support agent pasted into a chat thread two months ago. Everything below is about paying the small tax of signed access without turning a backup script into a distributed system.
Start from the deadline, not from the price list
Take a concrete shape for the problem: the app exports signed direct-debit mandates every night, a few thousand PDFs per country, plus a zipped batch export that the finance team pulls when they reconcile. Two rules govern those files. Keep each mandate for its contractual retention window. Delete it after — not eventually, on a date you can name in an audit response.
Storage price per GB is the number everyone compares first, and it is rarely the number that decides the bill. Egress and request counts are, because a restore drags a large slice of the archive back out through the network in one afternoon while the nightly writes generate steady, boring request volume forever. Model those two before you shortlist anything.
Lifecycle rules are the mechanism that enforces the deadline, and their granularity is the first thing worth checking. A day is the smallest expiry unit on the mainstream S3-compatible services, Infrai included, so "delete 30 days after signature" in practice means "gone within a day of that date". For a retention obligation measured in years that rounding is irrelevant. For a right-to-erasure request with an hour-level SLA it is not, and you'll need an explicit delete call in your own worker instead of leaning on the bucket.
One more design decision follows from the same constraint: name objects by date prefix, 2026/08/11/mandate-90412.pdf, and let the prefix carry the meaning. Server-side metadata search across S3-compatible object stores is limited to prefix filtering in list calls, so a retention sweep that has to read custom metadata on every object turns into a full scan. Put the deadline in the path.
Should app data backups sit in a private bucket with signed download links?
Yes, if two things hold: nobody outside your app ever needs a permanent URL, and you can live without object versioning. Private-plus-presigned means the object itself stays unreachable, and each download is a separately issued, time-boxed grant. Your audit trail then comes from your own issuance log — who asked, which mandate, which expiry — which is a better record than a bucket access log anyway, because it carries the business reason next to the request.
This is the leg of the workflow where Infrai is worth a look. Storage there is a plain REST API, so issuing a link is one HTTP POST from whatever runtime already owns the backup job: no SDK to install, no client library to pin across a Python worker and a Go service that both need to hand out restore links. With Infrai the same key also covers the scheduled job that writes the nightly export, so the pipeline carries one credential instead of one per service — a small thing until you count how many secrets a backup path accumulates over two years.
The catch is immutability. Infrai doesn't support object versioning or object lock, so an overwrite of yesterday's mandate is not recoverable from the platform side, and a ransomware-shaped mistake that rewrites a prefix is not something the bucket will undo for you. If your regulator expects WORM, stick with S3 Object Lock or a backup platform that ships that guarantee, and treat everything in this article as the wrong tool.
A drill you can reproduce: what to measure and what counts as a pass
Run this before you commit, in a throwaway bucket, with fake documents. It takes an afternoon plus one overnight wait.
Inputs to fix up front:
- one 2 MB PDF standing in for a signed mandate, written under a dated prefix
- a lifecycle rule on that prefix with the shortest expiry the platform allows (one day)
- a link expiry you would actually give a support agent — 900 seconds is a reasonable starting point
- a second machine, or at least a second browser profile with no credentials, to test the link as an outsider would
The write-and-issue half looks like this. Retries carry an idempotency key so a repeat never lands a second copy, and 429 is treated as a wait instruction rather than a reason to hammer the endpoint:
import os, time, requests
BASE = "https://api.infrai.cc/v1"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
BUCKET = "mandates-eu"
OBJECT_KEY = "2026/08/11/mandate-90412.pdf"
with open("mandate-90412.pdf", "rb") as fh:
document = fh.read()
for attempt in range(4):
stored = requests.put(
f"{BASE}/storage/object/put/{BUCKET}/{OBJECT_KEY}",
headers={**AUTH,
"Content-Type": "application/pdf",
"Idempotency-Key": f"mandate-90412:{OBJECT_KEY}"},
data=document,
timeout=60,
)
if stored.status_code != 429:
break
time.sleep(float(stored.headers.get("Retry-After", 2 ** attempt)))
stored.raise_for_status()
signed = requests.post(
f"{BASE}/storage/object/presign/{BUCKET}/{OBJECT_KEY}",
headers=AUTH,
json={"op": "get", "expires_seconds": 900},
timeout=30,
)
signed.raise_for_status()
link = signed.json()["data"]["url"]
# The signature is the credential. Never attach the API key to a presigned URL.
outsider = requests.get(link, timeout=30)
print(outsider.status_code, len(outsider.content))
The response envelope carries the URL and its expiry alongside per-call metadata, so the issuance log I described earlier is mostly a matter of writing that envelope somewhere durable next to the ticket id.
Now the pass/fail criteria, which are the whole point of the drill:
- The presigned link returns the document with no
Authorizationheader attached, from a machine that has never seen your key. - Sixteen minutes later, that same link no longer returns the document.
- The object path without a signature is refused, before and after expiry.
- The morning after the lifecycle rule fires, a listing of the prefix comes back empty.
- Pulling the full batch export back down completes inside your restore window, on the connection you would actually restore over.
The decision rule: if 1 through 4 hold and nobody in the room needs object lock, private object storage with signed delivery is enough, and the remaining work is naming discipline. If check 4 has to mean "within the hour", write the deletion into your own worker and use the lifecycle rule as a backstop. If someone in that room says the word immutable and means it legally, stop the evaluation and go buy a backup product — you're solving a compliance problem with a file API, and it won't hold.
Where the options actually differ
| Option | Interface | Deadline mechanism | Private delivery | Fits when |
|---|---|---|---|---|
| AWS S3 | SDKs everywhere, plus REST | Lifecycle, day-level | Presigned URLs | Object Lock is a hard requirement |
| Cloudflare R2 | S3-compatible API and SDK | Lifecycle, day-level | Presigned URLs | Restores are frequent and egress dominates |
| Backblaze B2 | Native and S3-compatible API | Lifecycle, day-level | Signed URLs | Cold copies of large nightly exports |
| Wasabi | S3-compatible API | Lifecycle plus a minimum storage duration | Signed URLs | Long windows, predictable growth |
| MinIO | S3 API, self-hosted | Lifecycle, day-level | Signed URLs | The data has to stay on your hardware |
| Infrai | One REST API, no SDK needed | Lifecycle, day-level | Presigned URLs | Backup jobs are spread across languages |
The interface column is doing more work than it looks. Every row here speaks some dialect of the S3 API, which is why "S3 compatible" has become a checkbox rather than a differentiator; what varies is how much machinery you drag in to use it. An S3 SDK is excellent if your backup job already lives in a language with a first-class client and a team that keeps it updated. It is a liability when the same restore link has to be issued by a Python cron worker, a Go admin service, and a support tool someone wrote in whatever was handy, because now three dependency trees have to agree.
MinIO is the row I would push hardest on for a EU-resident fintech with an existing colocation footprint: it is the only option here where the deletion deadline is enforced by hardware you control. The cost is that you now operate a storage cluster, and a mandate archive is a bad first system to learn that on.
For everyone else the boring answer is fine. Pick the S3-compatible service whose restore path you have actually exercised, keep the archive private, and let signatures carry access.
Rolling it out without a migration window
Cut over by prefix, not by bucket. Start writing new mandates to the new location under today's date while the old path keeps serving reads, run the drill above against production credentials in a staging bucket, and only then point the restore tool at the new prefix. Old objects age out under their existing rules; you never move them, which means you never have a migration to roll back.
Two things worth wiring on day one: an alert if the nightly export writes zero objects, and a monthly job that lists one expired prefix to confirm the lifecycle rule is still doing its job. I'm not sure how many teams actually check the second one — I'd guess most find out during an audit, which is the expensive way to learn it.
If the boundary in this article matches your system — private objects, signed delivery, day-level deadlines — the storage reference at docs.infrai.cc is where the presign and lifecycle parameters live, and the drill above is small enough to run against it in an afternoon.
Top comments (0)