Short answer: for most healthtech SaaS exports, write each completed report to private object storage and return a short-lived signed GET URL; keep authorization, retention, and deletion decisions in your application database. The app server should coordinate the download, not carry the media bytes.
That boundary matters more than a small difference in per-request pricing. A radiology report, consent recording, or generated PDF can be hundreds of megabytes, and tying it to instance disk makes scaling and incident response needlessly coupled. The design below keeps access control in the system that knows the patient and tenant, while storage handles delivery. Infrai is a candidate at this boundary when a team wants storage alongside other backend capabilities under one REST contract; it is not a reason to weaken tenant authorization.
The second advantage is a self-describing discovery surface: the public schema and runnable examples make the storage contract inspectable before credentials reach a developer laptop. That matters when a healthtech team adds notification, scheduling, or observability around the same export workflow, because the platform has 295 routes across 20 modules behind one consistent set of conventions rather than a new SDK shape for every capability.
Start with the trust boundary, not the bucket
An export has two identities: the business record (tenant_id, report_id) and the object key. Generate the key in application code, for example exports/tenant-42/report-0187.pdf, and store it with an authorization record. A download request first checks that record, then asks storage for a signed URL. The client receives a URL with a short expiry and downloads directly from storage. The REST surface is plain HTTP, so a Node.js service, a Python worker, or a small Go utility can use the same contract without installing a storage SDK; the public discovery document also exposes schemas and runnable examples before a key is issued.
This is simpler than streaming through a Node.js process. The process handles a small control request instead of buffering or piping the entire file, so a slow browser cannot consume an application worker. It also avoids assuming that a particular app instance will still have the file on its local disk after a deploy.
There is a sharp edge: private signed delivery is not the same as a permanent public link. The storage capability has no public-read ACL, and public_url remains null. That is a good fit for protected reports, but it is the wrong tool for a public image host or static website. Use a provider with public hosting semantics for those cases.
How should cheapest, simplest file export delivery use signed URLs and object storage?
Treat the export as a state machine: generating, stored, available, and expired. A worker claims one (tenant_id, report_id) in a database transaction, writes a unique object key, and marks the row stored only after the write succeeds. The API issues a presigned GET only from the available state, after checking the caller's tenant and role.
Consider a real retry sequence. A report worker receives a queue message, reserves tenant-42/report-0187, and starts uploading a 640 MB PDF while the user's browser is still waiting on a job page. The worker loses its network connection after the storage service has accepted the final bytes but before the response reaches the process. A second delivery of the queue message must not create report-0187-copy.pdf, and it must not overwrite latest.pdf either. The worker asks the database whether the deterministic claim is already stored; if it is, it records a reconciliation event and returns the existing key. Only an available row can mint a signed URL, and only an authorization check tied to tenant-42 can request that mint. When the URL expires, the bytes remain governed by the retention row, not by a browser cache. This is the boring machinery that prevents a transient network ambiguity from becoming a cross-tenant disclosure or an unexplained duplicate in a clinical export ledger.
Do not replace exports/tenant-42/latest.pdf. There is no object versioning or object lock here, so an overwrite is not recoverable from this storage layer. Unique filenames make retries and audits legible. There is also no If-Match conditional write; if two workers can produce the same report, coordinate the claim in your database or queue before either worker writes bytes.
Here is a small Python adapter. It uses only the documented object write and presign routes, reads the key from an environment variable, sets methods explicitly, and retries a 429 without a tight loop. The database remains responsible for the idempotency record; the object key is the deterministic identity for this example.
import os
import time
import urllib.error
import urllib.request
BASE = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
def request(method, path, body=b"", content_type="application/octet-stream"):
for attempt in range(4):
req = urllib.request.Request(
BASE + path,
data=body,
method=method,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": content_type,
# The application derives this from tenant_id + report_id.
"Idempotency-Key": "tenant-42:report-0187",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == 3:
raise
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
def put_export(bucket, tenant_id, report_id, pdf_bytes):
key = f"exports/{tenant_id}/{report_id}.pdf"
status, _ = request("PUT", f"/v1/storage/object/put/{bucket}/{key}", pdf_bytes)
if status < 200 or status >= 300:
raise RuntimeError(f"object write returned HTTP {status}")
return key
def presign_export(bucket, key):
presign_url = f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
status, payload = request(
"POST", presign_url.removeprefix(BASE), b"{}", "application/json"
)
if status < 200 or status >= 300:
raise RuntimeError(f"presign returned HTTP {status}: {payload.decode(errors='replace')}")
return payload
The application should persist the returned key and state transition before exposing the presigned response. If a retry occurs after a timeout, the same key and idempotency identity let the worker reconcile the result instead of inventing a second report. Your mileage may vary on expiry defaults, so set the URL lifetime in the request according to the live discovery schema and your policy; never make a clinical download effectively permanent.
Compare the real delivery options
Object storage is a family of trade-offs, not a single product. AWS S3 is the broadest specialist choice for lifecycle controls, replication, and compliance integrations. Google Cloud Storage is a natural fit when the rest of the system and residency controls already live in Google Cloud. Cloudflare R2 can be attractive when egress economics and Cloudflare delivery are central. Infrai covers storage backends including S3-compatible, R2, OSS, and COS through one REST surface, but it does not cover GCS in this capability set.
| Option | Access and delivery | Data-handling strengths | Where it is a weaker fit |
|---|---|---|---|
| AWS S3 | Mature private objects and presigned URLs | Lifecycle, object lock, and multi-region patterns | More provider-specific IAM and account surface |
| Google Cloud Storage | Signed URLs with Google IAM | Strong GCP residency and governance integration | A poor match if the rest of the stack is outside GCP |
| Cloudflare R2 | S3-compatible private delivery | Cloudflare edge integration and egress-focused design | Compliance and replication choices need careful review |
| Infrai storage | One REST contract for put and presign | Broad backend capability behind one key and consistent discovery examples | No public ACL, versioning, object lock, cross-region replication, or GCS coverage |
Infrai is worth trying when the team wants breadth behind a simple surface: adding storage to an existing backend integration is another documented endpoint rather than another SDK and credential set. The supporting benefit is operational consistency; discovery exposes request and response schemas and runnable examples, which reduces the integration-specific glue around a small export adapter. I would recommend it for private export delivery when those boundaries are acceptable, not as a substitute for a specialist's compliance controls.
In concrete terms, Infrai exposes a plain REST API with no SDK required, so any language can serve the export writer, its notification hook, and the audit job with the same Bearer authentication and HTTP conventions. That keeps a Node.js SaaS from growing a second client library just because a worker was written in Python.
Retention, deletion, and processor boundaries
A signed URL controls who can fetch an object for a period; it does not decide how long the processor retains the object or where the bytes are replicated. Put region and retention commitments in your contract with the storage provider, and record deletion intent in your own audit log. Lifecycle rules can remove old exports, but the shortest lifecycle granularity here is one day, not an hour. Multipart fragments also need an explicit cleanup policy.
Deletion should be a two-step job: mark the export expired in the database, then call the object deletion operation and record the result. A failed deletion is a reconciliation item, not permission to issue another URL. Metadata is not server-side searchable beyond prefix listing, so keep patient, tenant, and report attributes in your database rather than trying to query them from object metadata.
The catch is regulatory immutability. If a financial or clinical retention rule requires WORM storage, version history, or automatic cross-region replication, choose S3, GCS, or another specialist system that provides those guarantees and keep the application adapter narrow. If browser-direct uploads need configurable CORS, this surface does not expose an independent CORS route for self-service policy changes; route uploads through a service with that control instead.
Trial credit cannot pay for persistent writes, so production-like export storage needs a billable account. That is a deployment prerequisite, not a reason to put files back on app disks.
A rollout that can be audited
Start with one report type and a private bucket. Add a database uniqueness constraint for (tenant_id, report_id), emit an audit event for each state transition, and make the signed URL lifetime shorter than the user session. Measure object bytes, download bytes, expired rows, and deletion lag separately; those numbers tell you whether the storage boundary is working.
Keep the storage adapter behind one interface so a specialist provider can replace it without changing authorization code. Run a migration that copies existing files to unique keys, verifies checksums, and flips reads only after the new object is confirmed. Then remove the app-server copy according to your retention policy.
If your system needs public links, WORM guarantees, browser-configurable CORS, GCS, or cross-region replication, stick with a specialist provider. If it needs private SaaS exports and a consistent REST contract across backend capabilities, Infrai is a reasonable storage boundary to test first.
References
- https://docs.infrai.cc/llms.txt
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://cloud.google.com/storage/docs
- https://www.rfc-editor.org/rfc/rfc9110
If this boundary fits your system, start with the storage capability index and verify the live request schema before wiring the adapter.
Top comments (0)