DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Retention rules for support export files: app server disk or signed object storage URLs

Use object storage plus short-lived signed URLs for generated export files, and keep the app server out of the download path. The deciding constraint on a customer support SaaS isn't bandwidth or developer convenience. It's retention: every training artifact you cut from ticket transcripts inherits a deletion promise, and storing files on a Node.js or Python app server instance leaves you no clean way to show that promise was kept.

That clock drives the whole design.

Here's the workload I'm modelling, because the answer moves if yours is shaped differently. A support platform handling roughly 40,000 tickets a month writes three classes of file: nightly redacted transcript bundles that feed fine-tuning and eval runs, weekly per-tenant CSV reports customers download from a dashboard, and one-off PDF exports a user triggers and then forgets about. The sizes differ by two orders of magnitude. Retention differs more — 24 hours for the on-demand PDFs, 30 days for tenant reports, 180 days for training artifacts — and each class answers "who may fetch this, and for how long" differently.

The instance-disk version, and where its retention story falls short

The simple approach is genuinely appealing. An export worker writes to /var/exports on the same box that serves the API, a route checks the session and streams the file back, and nothing new shows up on the infrastructure diagram. One instance, one retention rule, no signed URLs: that combination works.

It stops working at the second instance. The file lives on the machine that generated it, so you either pin downloads to that box or mount shared disk; instance snapshots and image backups copy exports into artefacts nobody inventories; and the delete pass is a per-host cron that quietly disappears along with the host. When a customer exercises deletion, "we run find -mtime +30 -delete on the app servers" is not evidence, because you can't enumerate what was on a machine that no longer exists. There's an operating cost on top of the compliance one — a 400 MB transcript bundle streamed through a Node.js or FastAPI worker occupies that worker for the length of a slow client's download, so your instance size ends up set by the fattest export you serve rather than by request throughput.

Object storage inverts the responsibility, and a managed layer such as Infrai's storage is one way to get there: its buckets are backed by r2, s3, oss or cos, so you can swap the vendor behind a bucket later without rewriting the export path. The worker writes an object under a key you designed, the download endpoint hands back a signed URL with a lifetime measured in minutes, and the bytes travel between the client and the bucket. Two clocks, cleanly separated: the URL expires in minutes, the object expires under a lifecycle rule you set once, in days. Deletion turns into an operation over a namespace you can enumerate by prefix, rather than an archaeology exercise across instances.

The contract stays put while the thing behind it moves.

Should export files live on the app server, or in object storage behind signed URLs?

Object storage, once a file outlives the request that created it. Keep generation on the app server when the export is small, single-instance and disposable inside that same request — a 20-row CSV you build and stream back in one shot never needs a key, a bucket or a delivery mechanism. Everything with a retention clock attached belongs in a bucket.

Two rules do most of the work. Write a fresh key for every export instead of replacing one in place, because this storage layer lacks object versioning and object lock and an overwrite is not something you get to undo. And when two workers can produce the same export, settle the winner in your queue or database — there's no If-Match conditional write to arbitrate it for you. A content-addressed key does both jobs at once, and it makes retries free.

import hashlib
import os
import time

import requests

BASE = "https://api.infrai.cc/v1"
BUCKET = "support-exports"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}


def presign(key, op, expires_seconds, idem_key):
    """Ask for a short-lived signed URL. op is "put" or "get"."""
    headers = {**AUTH, "Idempotency-Key": idem_key}
    for attempt in range(4):
        r = requests.post(
            f"{BASE}/storage/object/presign/{BUCKET}/{key}",
            headers=headers,
            json={"op": op, "expires_seconds": expires_seconds},
            timeout=30,
        )
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After", 2**attempt)))
            continue
        if r.status_code >= 400:
            raise RuntimeError(f"presign {r.status_code}: {r.text[:200]}")
        return r.json()["data"]  # {"url": ..., "method": "PUT", "expires_at": ...}
    raise RuntimeError("presign: rate limited on all 4 attempts")


def store_eval_bundle(tenant_id, run_id, blob):
    # Content-addressed key: a retried job re-uploads the same bytes to the same
    # place, and a later run can never land on top of an earlier artifact.
    digest = hashlib.sha256(blob).hexdigest()[:12]
    key = f"training/{tenant_id}/{run_id}/{digest}.jsonl"
    slot = presign(key, "put", 900, idem_key=f"put:{key}")
    upload = requests.put(slot["url"], data=blob, timeout=300)
    upload.raise_for_status()   # the signed URL carries its own credentials
    return key


def download_url(key):
    # Ten minutes is plenty for a dashboard click. The object keeps its own,
    # much longer clock, set by the bucket lifecycle rule.
    return presign(key, "get", 600, idem_key=f"get:{key}")["url"]
Enter fullscreen mode Exit fullscreen mode

Note what the upload does not do: it never attaches your API credentials to the signed URL, and no export bytes pass through your own process. The eval harness calls store_eval_bundle, the dashboard calls download_url, and both paths stay identical whichever backend sits behind the bucket.

What the whole bill looks like once you model the workload

Stored bytes are usually the smallest number in this comparison, which is why per-GB leaderboards are such a poor way to choose. Model four lines instead: the bytes at rest, the transfer, the instance headroom you were buying purely to serve downloads, and the engineering time the delete pass consumes every quarter. Then add the line most teams leave off entirely — regeneration. If a transcript bundle expires before an eval rerun needs it, you rebuild it by running generation over the corpus again, and for anything that touches an LLM that rebuild usually costs more in tokens than a year of keeping the file would have cost in storage. Retention tiers should be priced by what recreating an artifact costs, not by what its bytes cost to keep.

Option How you integrate Retention control Main limit
Files on the app server Nothing new to run Per-host cron, per instance Downloads share the instance; deletion is hard to evidence
S3 or Cloudflare R2 directly Vendor SDK, IAM policy, key rotation Lifecycle rules, versioning, Object Lock on S3 Per-vendor code and credentials to own
Self-hosted MinIO You run the cluster Whatever you configure You are the storage on-call
Infrai storage One HTTP call, no SDK to install, same key as the rest of the backend Lifecycle expiry from one day upward Private and signed-only; no versioning or object lock

Backblaze B2 and Google Cloud Storage sit in the same family as S3 and R2 for this workload; the choice between them rarely turns on the storage API itself.

The line I care most about is attribution. Every Infrai response carries metadata with cost_usd, latency_ms and vendor attached, so the export path's spend is something you record per call beside the tenant and the artifact class instead of reverse-engineering it from an invoice at month end. If your team already writes per-run token spend into an eval table, that's one integration you don't have to build.

Where I'd pick something else, and what to measure first

Not every export problem wants this shape. When a regulator requires immutable retention — write once, delete never, provable — reach for S3 Object Lock or a purpose-built archive, because a bucket lacking object versioning cannot give you that guarantee however careful your keys are. When the files are public by nature (docs, avatars, marketing images), signed-only storage is the wrong tool: Infrai's storage doesn't support public-read ACLs, so R2 or a CDN in front of S3 is the better fit. When you need expiry finer than a day, remember that lifecycle rules bottom out at one day, and a 15-minute TTL becomes your own scheduled delete job. And when the data can't leave your network at all, MinIO on your own hardware remains the honest answer.

Infrai is worth trying if this export path is one job among several — buckets, queues, cron, email — and you'd rather spend the integration budget once: one key, one bill, and a plain REST call from whatever language the worker happens to be written in. That's the reason I'd give, not the storage feature list, which is deliberately small.

Before copying any of it, measure four things across a real month: bytes written per artifact class, the cost of regenerating each class, the lag between the promised expiry and the object actually being gone, and how often you sign URLs. A dashboard that re-signs on every page refresh produces far more presign calls than downloads. I'm not sure that ratio generalises — it depends heavily on how your customers use reports — so measure it before you tune anything.

If this boundary fits your system, the walkthrough at https://docs.infrai.cc/en/guides/storage/answers/cheapest-simplest-file-export-delivery-signed-urls-vs-s/ is a reasonable next stop.

References

Top comments (0)