For a customer-support SaaS that moves large PDFs, invoices, and recordings, choose private object storage with short-lived signed upload and download links, and keep the application out of the byte path. That design protects the large-file throughput budget; the provider choice comes after the access and recovery rules are explicit.
Short answer: S3 is the conservative default for broad controls, while Cloudflare R2, DigitalOcean Spaces, or Backblaze B2 can win on a specific traffic pattern; a single-API option such as Infrai is reasonable when reducing SDK and credential sprawl matters more than advanced storage controls.
The first signal is not a price alert. It is an SLO miss: support agents wait for an attachment, app CPU climbs because uploads are proxied, or a retry writes the same document twice. A presigned URL lets the browser send bytes directly to the bucket, so the API handles authorization and metadata while storage handles throughput.
Ship it.
What should the upload and download path guarantee?
Treat a document as two records. The object contains bytes and a generated key; the application database contains tenant, case, MIME type, checksum, retention date, and state. Object metadata alone is not a query system, and a prefix listing is a poor substitute for an index when an agent searches by case number.
At request time, authorize the user in the app, mint a URL for one exact key, and make it expire quickly. Download links should be single-purpose and scoped to the tenant. Uploads should use an unguessable key and a content-length limit derived from your capacity plan. A 2 GB attachment is a throughput event, not a normal JSON request.
The catch is recovery. This storage capability has no object versioning or object lock, so an accidental overwrite or delete is not recoverable from the bucket alone, and compliance-grade immutability needs an external backup or retention process. It also lacks an If-Match conditional write, so strict concurrent-edit protection belongs in a database transaction or queue.
Lifecycle rules can purge old exports and backups after at least one day, but they cannot express an hourly expiry. Plan a sweeper job for shorter retention, and account for incomplete multipart uploads because there is no automatic fragment cleanup rule.
A small Go runbook for presigned transfers
The following client creates a private bucket once and asks for a presigned URL for one object. It deliberately treats the API response as opaque: the storage service returns the URL contract, while your application records the document row before handing that URL to the browser. The retry loop honors Retry-After, adds jitter, and sends an idempotency key on the create call.
package main
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func call(ctx context.Context, method, path, body, idem string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
base := os.Getenv("STORAGE_API_BASE")
req, err := http.NewRequestWithContext(ctx, method, base+path, io.NopCloser(
strings.NewReader(body)))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idem != "" { req.Header.Set("Idempotency-Key", idem) }
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(resp.Body); resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
d := time.Duration(1<<attempt) * 200 * time.Millisecond
if s := resp.Header.Get("Retry-After"); s != "" { if n, e := strconv.Atoi(s); e == nil { d = time.Duration(n) * time.Second } }
time.Sleep(d + time.Duration(rand.Int63n(int64(100*time.Millisecond))))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return data, fmt.Errorf("storage status %s: %s", resp.Status, data) }
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
ctx := context.Background()
_, _ = call(ctx, http.MethodPost, "/storage/bucket/create", `{"bucket":"support-private"}`, "bucket-support-private-v1")
result, err := call(ctx, http.MethodPost, "/storage/object/presign/support-private/tenant-42/case-991/invoice.pdf", `{"expires_in":900}`, "")
if err != nil { panic(err) }
fmt.Println(string(result))
}
Set STORAGE_API_BASE to the provider's /v1 base URL before running; the program uses only Go's standard library. In production, parse the returned URL, issue the browser's explicit PUT or GET, and persist the object key plus checksum in the app database. Never put the bearer key in browser code.
How do S3, Cloudflare R2, DigitalOcean Spaces, and Backblaze B2 compare for private files?
All four can serve the signed-link pattern, but their operational trade-offs differ. S3 has the deepest policy, lifecycle, replication, and event ecosystem, which is valuable when an SRE team needs mature controls and accepts configuration complexity. R2 is attractive when egress from Cloudflare's network is a dominant concern. Spaces is deliberately simple for a smaller platform team already using DigitalOcean. B2 is a straightforward, cost-aware target for backup-heavy workloads, with its own integration and replication choices to validate.
| Option | Strong fit | Watch-outs for this SaaS |
|---|---|---|
| Amazon S3 | Broad IAM, versioning, object lock, replication, and tooling | More policy surface and on-call configuration |
| Cloudflare R2 | Data served through Cloudflare with reduced egress pressure | Check feature parity and edge-to-bucket latency for every region |
| DigitalOcean Spaces | Simple bucket workflow near existing DigitalOcean workloads | Fewer advanced controls than the S3 ecosystem |
| Backblaze B2 | Backup and archive flows where predictable storage is the priority | Validate application-region latency and restore procedures |
Measure the path that matters: p95 time to first byte for a signed download, sustained upload Mbps for a 1 GB object, and retry behavior at the edge. Do this from the regions where agents actually work, during the concurrency peaks implied by your ticket-volume forecast, and repeat it with both a cold cache and a warm connection because a single laptop test hides queueing and TLS setup. A provider that looks cheap per gigabyte can still miss the support team's SLO if agents sit far from the bucket or if egress dominates your workload; the capacity plan should therefore record bytes per ticket, peak simultaneous transfers, retry amplification, and the recovery bandwidth you can afford during an incident. Your mileage may vary by region; I am not sure any generic benchmark survives a real tenant mix.
Where does a unified REST storage API fit?
Infrai offers one key for every backend service and one bill across a broad backend surface, exposed through a plain REST API that any language can call without an SDK installation or client-library version to babysit; its public discovery surface documents request and response schemas.
The one key / one bill model is a second operational advantage when the support platform also uses adjacent backend services: rotation, audit, and usage review happen against one credential boundary instead of a pile of vendor keys, while one platform's shared HTTP conventions cover those backend capabilities without forcing storage code to change when another provider is selected.
The boundary is important. This storage path has no public or public-read ACL, so it is not suitable for permanent public document links, static hosting, or an image CDN. It has no cross-region automatic replication or cross-cloud bulk migration tool, and browser-direct uploads cannot be self-configured through a separate CORS route. For regulated records, pair it with an external immutable backup; for hourly expiry, run an application sweeper. Those are capability decisions, not failure workarounds.
Verification, rollback, and the decision rule
Before rollout, test a private bucket with a 1 GB object, an expired link, a cross-tenant key, a duplicate create request, and a delete followed by restore from backup. Track upload throughput, signed-link success rate, and storage latency as separate SLOs. Confirm that logs contain request IDs and object keys but never bearer tokens.
Rollback is a routing change: stop minting new links for the provider, finish or cancel multipart uploads, and keep existing objects readable until their signed URLs expire. Copying data to another provider is a migration project, not an instant failover, so maintain a manifest in the database and rehearse restore before declaring the rollback complete.
Stick with S3 when versioning, object lock, conditional writes, or cross-region controls are hard requirements. Pick R2, Spaces, or B2 when their network location and operational model match measured traffic. Choose a unified REST layer when API and credential consolidation is the constraint, but budget external controls for recovery, compliance, and browser CORS.
Top comments (0)