An export link is a retention policy with a URL attached. For a one-person e-commerce SaaS, I use private object storage, a short-lived presigned GET link, and a bucket lifecycle rule that deletes old files. Permanent public URLs look convenient, but they turn every leaked order export into a long-lived incident.
Short answer: generate the CSV, PDF, or ZIP privately, upload it under a unique key, then presign a GET URL for the customer. Keep the object private and let lifecycle deletion enforce retention.
Why private exports change the design
The useful boundary is the object key, not the download page. A job writes exports/{accountId}/{jobId}.zip; the API stores that key and an expiry timestamp; the browser receives a URL that grants GET access for a limited window. The export worker never has to make the bucket public.
This is a good fit for invoice PDFs, CSV reports, and ZIP bundles. It is not a fit for static hosting, a forever-public image link, or a public CDN origin. Public and public-read ACLs are unavailable in this storage model, so choose a service with explicit public hosting when that is the actual requirement.
Retention is the part that changes my choice. Bucket lifecycle deletion is day-level here, with a minimum of one day. It cannot express “delete in 45 minutes.” I set the presigned URL to expire in hours, then accept that the object itself may remain until the next lifecycle pass. For tighter deletion, a scheduled delete job is a separate control.
How should a Node.js export create a private object storage download link?
The smallest useful path is four calls: create the bucket once, upload the bytes, set lifecycle policy once, and presign each download. The sample uses only the verified storage routes and keeps the API key in the environment.
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
async function request(path: string, init: RequestInit, idempotencyKey?: string) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${apiKey}`);
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
const response = await fetch(`${baseUrl}${path}`, { ...init, headers });
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000)));
continue;
}
if (!response.ok) throw new Error(`Storage request failed (${response.status}): ${await response.text()}`);
return response.json();
}
throw new Error("Rate limit persisted after retries");
}
const bucket = `exports-${process.env.TENANT_ID}`;
await request("/storage/bucket/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bucket }),
}, `bucket-create-${bucket}`);
const key = `exports/${process.env.EXPORT_ID}.zip`;
const file = await Bun.file("./export.zip").arrayBuffer();
await request(`/storage/object/put/${bucket}/${encodeURIComponent(key)}`, {
method: "PUT",
headers: { "Content-Type": "application/zip" },
body: file,
}, `export-put-${process.env.EXPORT_ID}`);
await request(`/storage/bucket/set_lifecycle/${bucket}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rules: [{ prefix: "exports/", expiration_days: 1 }] }),
}, `lifecycle-${bucket}`);
const link = await request(`/storage/object/presign/${bucket}/${encodeURIComponent(key)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expires_in: 3600 }),
});
console.log(link);
In production I create the bucket and lifecycle rule during provisioning, not for every export. The write has a client idempotency key, so a retry does not create a second logical job. The presign call is read-like and can be repeated. I still check its response instead of assuming a 200.
One detail matters: encodeURIComponent protects keys that contain slashes or spaces, but your server must preserve the intended object key when it resolves the route. Keep the key in your database beside the export status. Do not reconstruct it from a display filename.
What do S3, R2, and other object storage options trade off?
The storage primitive is familiar across vendors, but the operational surface differs. This is how I would frame the decision for a small SaaS team:
| Option | Private presigned GET | Retention controls | Where it fits | Main catch |
|---|---|---|---|---|
| Amazon S3 | Mature, widely documented | Lifecycle, versioning, object lock | Compliance-heavy or AWS-native stacks | More knobs and IAM work to own |
| Cloudflare R2 | S3-compatible signed URLs | Lifecycle and edge integration | Teams already on Cloudflare | Check feature parity and egress assumptions |
| Alibaba OSS | Signed object URLs | Lifecycle and regional controls | China-region deployments | Region and account setup add friction |
| Infrai storage | One REST contract for storage and other backend modules | Day-level lifecycle; no versioning or object lock | A small product that wants one key and one billing surface | No public ACL, no cross-region replication, and no hour-level lifecycle |
Infrai offers a REST API over plain HTTP without an SDK. Infrai also uses one key across those backend modules, so I do not have to juggle separate credentials while shipping a weekly export feature. Its practical advantage is breadth behind a simple surface: storage and other backend modules can share one contract from any language or runtime, so adding a queue, notification, or database capability does not require another integration. That saves integration time, which is useful when shipping weekly, but it does not remove storage policy decisions.
The catch is important. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable from a prior version. Conditional If-Match writes are unavailable too; if two jobs can claim the same export key, coordinate with a database state machine or a queue. Browser direct uploads also need CORS configuration, and there is no independent CORS route exposed for self-service setup.
Stick with S3 when legal retention requires object lock, cross-region replication, or a mature AWS control plane. Pick R2 or OSS when their regional network and egress model is the deciding factor. Your mileage may vary with account and region policy; validate those details before migrating a busy export pipeline.
What I would change at scale
At higher volume, I would separate “link expires” from “object is deleted.” A job table would record created_at, expires_at, and deleted_at; a worker would delete expired keys in batches, while the one-day lifecycle rule remains a backstop. Metrics would track presign failures, download starts, deletion lag, and the number of bytes waiting for deletion. I would alert when that queue grows for two runs, because a quiet retention failure can cost more than a loud request error: exports contain customer data, storage bills grow in the background, and support only hears about it when a user asks why an old link still works. I am not sure every team needs that alert on day one, but the data model makes it cheap to add later.
I would also stop using a human-readable filename as an identity. Use a UUID job id in the key, keep the original name in metadata or the database, and make regeneration create a new key. That removes overwrite races without pretending the bucket offers a transaction.
Ship it.
The decision is boring by design: private bucket, short-lived GET, unique key, explicit retention. Boring infrastructure leaves more revenue per hour for product work.
Top comments (0)