Delivering CSV exports: signed URLs or files on the app server?

A cost and operations comparison for SaaS report downloads in Node 22, with the export-to-bucket flow, lifecycle expiry and the usage query that tells you the real bill.

Put the file in a bucket and hand the user a signed URL. Writing exports to the app server’s disk is simpler for exactly one deployment — a single long-lived box — and becomes a bug the day you run two containers, because the request that asks for the download lands on the instance that doesn’t have the file. Infrai signs those URLs for free; you pay for the write, the stored bytes and the egress.

That’s the recommendation. What follows is the arithmetic behind it, a working Node 22 export path, and the two cases where the app server really is the right answer.

Three cost lines, only one of which is obvious

Bytes at rest are the line everyone budgets for and they’re rarely the problem — a month of CSV reports for a mid-size SaaS is measured in gigabytes, not terabytes. Egress is the line that surprises people, because a 40 MB export downloaded three times is 120 MB leaving the platform. The third line has no invoice at all: the engineering hours spent on a disk that fills up, a volume that needs snapshotting, and a cleanup cron that deletes the wrong thing at 3 a.m.

Signed URLs remove the third line entirely. That’s most of the value.

The export path, start to finish

Generate, store, sign, return. This runs on Node 22 with no dependencies:

const API = "https://api.infrai.cc";
const BUCKET = "exports-demo";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };

function toCsv(rows) {
  const header = "date,calls,cost";
  const body = rows.map((r) => `${r.date},${r.calls},${r.cost}`).join("\n");
  return `${header}\n${body}\n`;
}

export async function buildExport(tenantId, rows) {
  const month = new Date().toISOString().slice(0, 7);
  const key = `exports/${tenantId}/${month}/usage.csv`;
  const csv = toCsv(rows);

  const payload = { data_base64: Buffer.from(csv, "utf8").toString("base64"), content_type: "text/csv" };
  const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: auth,
    body: JSON.stringify(payload),
  });
  const stored = await put.json();
  if (!put.ok || stored.ok === false) throw new Error(stored?.error?.code ?? `HTTP ${put.status}`);

  const linkRequest = { op: "get", expires_seconds: 900 };
  const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    headers: auth,
    body: JSON.stringify(linkRequest),
  });
  const link = await signed.json();
  if (!signed.ok || link.ok === false) throw new Error(link?.error?.code ?? `HTTP ${signed.status}`);

  return { key, bytes: stored.data.size_bytes, url: link.data.url, expiresAt: link.data.expires_at };
}

Your download endpoint doesn’t stream anything. It looks up the key, signs a fresh URL and returns a 302 — the bytes go from the vendor straight to the user’s browser and never touch your process, so a 200 MB export costs you no memory and no request-thread time.

The signing call on its own:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/exports-demo/exports/tenant_42/2026-07/usage.csv" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":900}'
{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.exports-demo/exports/tenant_42/2026-07/usage.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=ec83024e2df6718e",
    "expires_at": "2026-07-26T01:02:47.596541Z"
  }
}

A download URL comes back leaner than an upload one — no method, no signed headers to reproduce, just a link you can hand to a browser. Signed download responses carry Content-Disposition: attachment, so the file saves instead of rendering as a wall of CSV. Fifteen minutes is a sensible lifetime; anything longer tends to end up pasted into a shared channel.

Let the bucket do the cleanup

The cleanup cron is the part of the disk design that rots. Replace it with a lifecycle rule — one call, and old exports delete themselves:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/exports-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"exports/","expire_days":7}]}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_f338b14dccf84a95be2fd8",
    "name": "exports-demo",
    "vendor": "cos",
    "region": "eu-central-1",
    "acl": "private",
    "cors_rules": [],
    "lifecycle_rules": [{ "prefix": "exports/", "expire_days": 7 }]
  }
}

The submitted list replaces the previous one rather than merging, so send every rule you want to keep. expire_days has to be at least 1 — there’s no “delete after six hours” granularity here, and if you need same-day expiry you’ll have to delete explicitly.

Watching what it actually costs

Two queries, both free. Bucket-level bytes:

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/exports-demo" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

And the account-level spend, broken down by capability:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print([b for b in d['breakdown'] if b['key'].startswith('storage')])"

Verified 26 July 2026: writing an object is $0.0001 per call, signing, listing, head and lifecycle changes are free and rate-limited, and stored bytes plus egress are metered separately. Five thousand exports a month is $0.50 in write fees — the bytes and the bandwidth will dominate long before the calls do. Rates on this platform move downward and campaigns run, so read the live figure rather than budgeting from this page:

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; print([ (c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.') ])"

Four delivery designs compared

DesignSurvives a second app instanceMemory per downloadCleanupWhere it fits
File on the app server’s diskNoStreaming, but your processYour cron, your bugSingle-box internal tools
Bucket + signed URL redirectYesNoneLifecycle ruleThe default for SaaS reports
Bucket + stream through your APIYesOne buffer per requestLifecycle ruleWhen you must audit every byte served
Cloudflare R2 directYesNoneLifecycle ruleVery heavy egress — R2 doesn’t charge for it

That last row is a genuine boundary. If exports are your product and you’re pushing terabytes a month, R2’s zero-egress pricing is hard to argue with, and Backblaze B2 is worth pricing for cold archives. Object storage is a commodity; the reason to keep exports on Infrai isn’t a cheaper byte, it’s that the cron that schedules the export, the queue that builds it, the email that delivers the link and the error tracker that catches the failed job are all on the same key and the same invoice — no second vendor, no second SDK, no reconciliation at month end.

When the app server wins

Two cases, honestly. A single-instance internal tool where the export is read once and thrown away doesn’t need any of this; a temp file and res.download() is less code and fewer moving parts. And an export that must never leave your network — some regulated deployments — is a case where object storage isn’t the right tool at all, and a self-hosted MinIO behind your own firewall is the closer fit.

Limits worth knowing

The base64 put path isn’t recommended above 1 MB, so a large CSV wants a presigned upload or multipart instead. Sustained heavy downloading can trip STORAGE_BANDWIDTH_EXCEEDED, which is a rate signal rather than a hard cap. Buckets are private or signed-only — there’s no permanent public link, which rules out pasting a URL into a static email template. And a signed URL, once issued, is a bearer token for that object until it expires: keep the lifetime short and re-sign per click rather than storing links in your database.

References

Browse more storage developer guides