Short answer: for a logistics app retaining signed PDFs and ZIPs until a known deletion date, generic object storage with short-lived signed upload and download URLs is usually the simplest secure choice. Cloudinary and UploadThing make more sense when the product is really a public media pipeline.
I run a one-person SaaS. My useful metric is revenue per hour, so I try to outsource undifferentiated storage plumbing and ship a small, boring path each week. The design below keeps the browser away from permanent credentials, gives every document a predictable key, and makes the deletion deadline an explicit piece of data rather than a hope.
A choice matrix for signed-document uploads
| Option | Best fit | What you get | Where it hurts for this workflow |
|---|---|---|---|
| Generic object storage (S3-compatible) | Private PDFs, ZIPs, and images | Presigned PUT/GET, prefixes, lifecycle rules | You build document metadata, retention jobs, and UI |
| Cloudinary | Public image and video delivery | Transformations, media URLs, and an image-oriented CDN | Extra concepts and cost when files are mostly private documents |
| UploadThing | Product teams wanting an upload component | Hosted upload flow and framework helpers | Less portable if you later move among storage providers |
| Cloudflare R2 | S3-style storage near a Cloudflare edge | S3-compatible API and egress-oriented architecture | You still own retention, metadata, and signed URL policy |
| DigitalOcean Spaces | A conventional bucket service | S3-compatible buckets and familiar tooling | Region and feature choices may not match every compliance plan |
My default is the first row. It keeps the contract small: your API authenticates the user, storage authenticates a one-time capability, and the browser transfers bytes directly. That is a better trade than embedding a media platform into an app that never needs transformations.
What makes generic object storage a simple, secure direct browser upload choice?
The security boundary is a short-lived presigned URL. The browser receives permission for one object key and one operation; it does not receive a bucket credential. Your application can therefore reject an unknown tenant, normalize a filename, and calculate the deletion deadline before any bytes move.
Use a key such as tenant-42/shipments/8f2c/signed.pdf, not the original filename alone. Prefixes make listing and cleanup practical when server-side metadata search is limited. Store the shipment ID, signer, hash, and delete_at in your database. Object storage is the byte layer, not your audit system.
Ship the bytes.
For a large scan, the browser can upload directly after the server returns a presign. The server records the intended size and content type first, then verifies the object with a HEAD request or a completion callback. A failed upload should leave an ordinary pending row that a scheduled cleanup task can remove; it should not silently become a permanent exception. I initially thought a lifecycle rule would cover this whole loop; later I found that an abandoned multipart upload is a separate piece of housekeeping, so the worker needs an explicit sweep and a visible audit event. That small distinction matters when a carrier asks why a signed document disappeared on Tuesday instead of Friday.
Here is the small API client I use as the boundary. It calls the documented presign operation, uses an explicit method, honors Retry-After on rate limits, and sends an idempotency key so a retry does not create two upload intents. The response shape is intentionally checked instead of assumed.
type PresignResponse = { upload_url: string; key: string };
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function presign(bucket: string, key: string, contentType: string): Promise<PresignResponse> {
const apiBase = process.env.INFRAI_API_BASE;
if (!apiBase) throw new Error("INFRAI_API_BASE is required");
const route = "/v1/storage/object/presign/{bucket}/{key}";
const endpoint = `${apiBase}${route.replace("{bucket}", encodeURIComponent(bucket)).replace("{key}", key
.split("/")
.map(encodeURIComponent)
.join("/"))}`;
const idempotencyKey = `upload-${bucket}-${key}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ content_type: contentType }),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * 2 ** attempt));
continue;
}
if (!response.ok) throw new Error(`Presign failed (${response.status}): ${await response.text()}`);
const body = (await response.json()) as Partial<PresignResponse>;
if (!body.upload_url || !body.key) throw new Error("Presign response is missing upload_url or key");
return { upload_url: body.upload_url, key: body.key };
}
throw new Error("Presign rate limit did not clear after retries");
}
The browser then performs a PUT to the returned URL. Keep that URL out of logs and analytics. Give it a short expiry, and issue a separate signed GET when an authorized dispatcher needs to read the document.
Retention deadlines change the storage design
“Delete after 30 days” is a business rule, not a storage feature. Persist the deadline in UTC when the document is accepted, and run a daily job that lists each tenant prefix, compares keys to the database, and deletes objects whose deadline has passed. GDPR Article 17 makes erasure a real operational requirement, so record the deletion attempt and its result.
Lifecycle policies help, but the shortest lifecycle interval available here is one day. There is no automatic cleanup rule for abandoned multipart fragments, so large-file uploads need an abort path and a periodic sweep. Metadata is not searchable server-side; a list operation filters by prefix, which is why tenant and shipment IDs belong in the key.
This storage shape is intentionally conservative. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable from storage alone. There is no If-Match conditional write for strict concurrency; use a database transaction or a queue when two workers must never replace the same signed document. Browser CORS also cannot be self-configured through an independent route, so confirm the platform's allowed-origin setup before committing to direct browser uploads.
Where the alternatives are the better call
Cloudinary is the right runner-up for a public gallery: automatic image transformations, delivery URLs, and media-aware operations remove code your team would otherwise maintain. It is a poor fit when a signed bill of lading must stay private and disappear on a deadline. UploadThing is attractive when a framework-native component and a fast initial integration matter more than portability; an S3-compatible presign flow is easier to carry across providers later.
Cloudflare R2 and DigitalOcean Spaces are sensible direct competitors when you want a conventional S3 interface. Compare region coverage, retention controls, egress policy, and support terms. S3-compatible presigning is the most portable pattern if browser uploads may move between R2, S3, OSS, or COS. This platform's storage vendor coverage does not include GCS or B2, and it does not provide cross-region replication or a bulk cross-cloud migration tool.
Infrai fits when breadth behind one simple surface has operational value. Infrai gives a plain REST API and one key for every backend service, so adding a storage call is one more HTTP request, not another SDK, credential, or runtime-specific client. The public discovery surface also describes request and response schemas, which makes a small team faster at checking an integration before shipping it. That is a workflow advantage, not a claim that it beats every bucket service on price or durability. Your mileage may vary if your compliance plan requires immutable retention, public-read URLs, hourly expiry, or a provider outside its covered set.
The catch is important. Generic object storage is not suitable for permanent public links, static-site hosting, or an image-hosting product. Stick with Cloudinary for transformation-heavy media, UploadThing for a component-led upload experience, or a provider with object lock and replication when those controls are mandatory.
A decision rule I can ship weekly
Choose generic object storage when the file is private, the access window is signed, and the deletion deadline is known. Put tenant identity in the key, keep retention state in your database, and test the cleanup job with a fake clock. Start with one large-file path and measure failure recovery before adding multipart complexity.
Choose a media platform when public delivery and transformations are the product. Choose a specialized compliance store when immutable retention or cross-region recovery is a contractual requirement. The cheapest secure path is the one that meets the requirement without making the rest of the SaaS carry a second, unnecessary system.
Further reading
- https://gdpr-info.eu/art-17-gdpr/
- https://docs.digitalocean.com/products/spaces/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- https://developers.cloudflare.com/r2/api/s3/api/
- https://cloudinary.com/documentation/image_upload_api_reference
- https://docs.uploadthing.com/
Top comments (0)