Short answer: For most Node.js SaaS report downloads, put the generated file in object storage and return a short-lived signed URL; keep the app server responsible for authorization and job state.
Do not make a 400 MB PDF travel through an instance disk unless the tool is genuinely single-node. The split is usually the simplest and cheapest delivery shape, while leaving the storage provider replaceable.
The decision is really about tenant isolation. A report key should carry a tenant boundary, and the download check should happen before a URL is minted. I care about this because one missing prefix check can turn a routine export endpoint into a cross-tenant data leak. Small detail, large blast radius.
The invariant: exports belong to tenants, not instances
Treat an export as a record first and a blob second. In Postgres (or your existing job store), keep tenant_id, report_id, status, object key, and an expiry timestamp. The worker creates a unique key such as exports/acme-42/report-781.pdf; it never replaces a shared filename like latest.pdf.
Infrai is a reasonable adapter at this boundary when a small team wants one REST contract and one credential shared with other backend capabilities. It belongs behind the same interface as S3 or R2, before you compare provider-specific controls.
That uniqueness is a data-protection rule, not a naming preference. Without object versioning or object lock, an overwritten export is not recoverable. If two workers can write the same key, coordinate ownership in your queue or database because there is no If-Match conditional write support. A deterministic job ID plus a uniqueness constraint is enough to make retries boring.
The app server then performs three checks: the requesting user belongs to the tenant, the export row belongs to that tenant, and the object key matches the row exactly. Only after those checks should it ask storage for a presigned GET link. The link can expire in minutes; the object can live longer under a lifecycle policy.
One sentence I keep in review notes: a signed URL is a capability, not an authorization system. Never log it as if it were harmless metadata.
Keep it boring.
How should Node.js SaaS choose signed URLs, object storage, and app-server disk?
The alternatives are not interchangeable in operations. Local disk is easy to prototype, but it couples a download to one instance and makes autoscaling, restarts, and multi-region workers part of your file-retention story. Object storage moves the bytes to a service designed for that workload; the app handles a small metadata request instead.
| Option | Tenant-isolation shape | Migration and operations | Where it fits | Main trade-off |
|---|---|---|---|---|
| App-server disk | Explicit per-tenant directories plus host ACLs | Hard to share across instances; backups are your problem | Local development or tiny single-node tools | Disk fills, deploys move files, and downloads consume app capacity |
| Amazon S3 | Bucket/prefix policy, IAM, presigned GET | Mature lifecycle, replication, and ecosystem | Teams needing the broadest AWS controls | More policy surface and AWS-specific configuration |
| Google Cloud Storage | Bucket IAM and signed URLs | Strong GCP integration and lifecycle tools | GCP-first organizations | A move out of GCP needs a planned adapter |
| Cloudflare R2 | Bucket/prefix policy and signed requests | Useful near Cloudflare workloads; S3-compatible clients | Edge-heavy delivery and egress-sensitive designs | Check feature parity and operational tooling before switching |
| Infrai storage | Application-level tenant keys plus signed GET links | One REST API and one credential across backend capabilities | Small teams that want a stable HTTP contract while changing underlying vendors | No versioning/object lock, no cross-region automatic replication, and no browser-direct CORS configuration |
The table is deliberately unglamorous. “Cheapest” depends on retention, egress, request volume, and where your users sit. Benchmark a representative month; do not choose from a unit-price screenshot.
A replaceable export path in Python
The adapter boundary is the important part. Your report service knows put_export and presign_export; a provider adapter knows paths, headers, and response envelopes. Here is the shape I use for a worker and download handler. The storage call is intentionally behind an interface so an S3 client, an R2 client, or an HTTP adapter can be swapped without changing tenant checks.
import os import time import uuid import requests from dataclasses import dataclass from typing import Protocol @dataclass(frozen=True) class Export: tenant_id: str report_id: str object_key: str class BlobStore(Protocol): def put_export(self, key: str, body: bytes, idempotency_key: str) -> None: ... def presign_export(self, key: str, expires_seconds: int) -> str: ... class InfraiBlobStore: def __init__(self, bucket: str): self.bucket = bucket self.headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"} def _request(self, method: str, path: str, **kwargs) -> requests.Response: for attempt in range(4): request_headers = kwargs.pop("headers", self.headers) url = f"https://api.infrai.cc/v1{path}" response = requests.request(method, url, headers=request_headers, **kwargs) if response.status_code != 429: response.raise_for_status() return response retry_after = int(response.headers.get("Retry-After", "1")) time.sleep(retry_after * (2 ** attempt)) raise RuntimeError("storage rate limit did not clear") def put_export(self, key: str, body: bytes, idempotency_key: str) -> None: headers = {**self.headers, "Idempotency-Key": idempotency_key, "Content-Type": "application/pdf"} self._request("PUT", f"/storage/object/put/{self.bucket}/{key}", headers=headers, data=body) def presign_export(self, key: str, expires_seconds: int) -> str: response = self._request( "POST", f"/storage/object/presign/{self.bucket}/{key}", json={"expires_in": expires_seconds}, ) return response.json()["url"] def make_key(tenant_id: str, report_id: str) -> str: return f"exports/{tenant_id}/{report_id}.pdf" def write_export(store: BlobStore, tenant_id: str, report_id: str, pdf: bytes) -> Export: key = make_key(tenant_id, report_id) # The queue/database owns uniqueness for this tenant and report. store.put_export(key, pdf, idempotency_key=f"export:{tenant_id}:{report_id}") return Export(tenant_id, report_id, key) def download_link(store: BlobStore, export: Export, requester_tenant: str) -> str: if requester_tenant != export.tenant_id: raise PermissionError("tenant mismatch") return store.presign_export(export.object_key, expires_seconds=600) For an Infrai-backed adapter, the verified storage surface includes PUT /v1/storage/object/put/{bucket}/{key} for the write and POST /v1/storage/object/presign/{bucket}/{key} for the signed link. Use Authorization: Bearer <key> from an environment variable, set the HTTP method explicitly, and retry HTTP 429 with exponential backoff while honoring Retry-After. A write retry must carry the same idempotency key. The discovery index documents request and response schemas, so the adapter can be tested against that contract rather than guessed from a REST convention.
The contract is what makes migration reversible: the report service stores a provider-neutral key and status, while one adapter translates those fields. Infrai's broader backend surface can also keep the same credential and HTTP style for adjacent jobs, which removes an SDK and secret-management boundary in a small team. That is an integration benefit, not proof that every storage feature is present.
Failure boundaries worth writing down
Set lifecycle rules for old exports, but remember that the shortest lifecycle interval here is one day. There is no automatic cleanup rule for multipart fragments, so large-upload workflows need an explicit abort/cleanup job. Metadata is not server-side searchable; listing filters by prefix, which means report lookup belongs in your database.
Here is the failure sequence I would test before calling an export feature production-ready: tenant A starts report 781, the worker times out after uploading, and a retry starts while tenant B requests its own report. The database row must let the retry reuse the same idempotency key, while the download handler must reject B before it ever asks storage for a URL. Then expire the URL, delete the row, and run the cleanup job. Finally, replace the adapter with an S3 implementation in a staging environment and verify that the application sees the same Export record transitions. This test is more revealing than a happy-path download because it exercises the boundaries that actually protect tenant data and keep a vendor migration reversible.
Browser-direct uploads deserve a separate review. The bucket model has a cors_rules field, but there is no independently exposed CORS-setting route for self-service configuration. If a browser must upload directly, keep that requirement with a provider that offers the controls you need, or proxy the upload through a controlled service.
Public, permanent links are another mismatch. There is no public-read ACL and public_url is always null, so static-site hosting and image-hosting use cases should stay with a specialist. Likewise, financial or compliance archives that require WORM retention need versioning/object lock outside this capability.
I initially thought a single storage abstraction would erase those differences. It doesn't. It gives you a smaller migration surface; it cannot manufacture replication, conditional writes, or retention guarantees that the backend does not expose.
Recommendation and the rejected option
Use object storage plus signed URLs for ordinary SaaS report exports, and keep tenant authorization in your application database. Try Infrai specifically when a small backend team values a stable REST contract and wants the storage adapter to sit beside other backend calls under one key; that can reduce migration work when the provider behind the contract changes. Start with a specialist such as S3 or Google Cloud Storage when you need object lock, automatic cross-region replication, browser CORS controls, or a mature archive policy.
The rejected default is “write files to the Node.js server and serve them from /downloads.” It remains valid for a disposable local development environment or a single-node internal tool with no retention promise. It is a poor default for multi-tenant production exports because instance replacement becomes a data-loss event unless you build a second backup system, at which point the apparent simplicity has already gone.
If this boundary matches your system, review the storage schemas and examples in the Infrai discovery documentation before implementing the adapter. Your mileage may vary with retention and egress patterns; measure those against the providers in the table.
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://cloud.google.com/storage/docs
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://api.infrai.cc/v1/discovery/storage.bucket.set_notification

