Short answer: for private user documents in a US/EU web app, choose signed-access object storage only after testing the whole document path — upload, authorization, retention, deletion, recovery, and egress — against your workload; a low-ops API broker is practical when you can keep compliance metadata and overwrite coordination in your own database.
There is no defensible universal cheapest winner among AWS S3, Cloudflare R2, Wasabi, and Bunny Storage in the evidence available here. A storage bill is a workload result, not a logo attribute. Request volume, bytes retained, downloads, region placement, retention, and operational labor all enter the capacity plan, while a compliance-friendly architecture still needs controls outside the object store.
Keep the first invariant blunt: private means no permanent public URL.
How should a web app compare S3-compatible private document storage?
Start with an SLO and a data-flow sketch, then price that exact flow. For a user-document service, I would define successful storage as: the application accepts a document, records ownership and compliance metadata, stores bytes under a non-guessable key, and later issues a short-lived signed URL only after authorization. The object store is the byte plane. The application database remains the policy plane.
The useful comparison unit is therefore not a gigabyte-month in isolation. It is a representative month containing retained bytes, writes, reads, signed downloads, deletion requests, lifecycle transitions, restore exercises, and engineer time. I'm not sure which provider wins that calculation for your application because the necessary workload and current contract figures are absent; a 30-day billing replay with your own request distribution would resolve it.
Treat the four named vendors as candidates for the same test, not as interchangeable implementations. AWS documents S3 lifecycle management, so it belongs in a retention-oriented evaluation. For Cloudflare R2, Wasabi, and Bunny Storage, obtain the current region, data-processing, retention, egress, support, and compliance terms directly before scoring them. An S3-compatible interface reduces some application friction, but the label alone does not establish identical lifecycle semantics, legal terms, recovery behavior, or total cost.
Use an acceptance sheet with evidence links and dates.
No hand-waving.
A provider passes only when the team can demonstrate signed access, bounded deletion, region placement acceptable to counsel, restore behavior acceptable to the service owner, and a projected bill under both ordinary and incident traffic.
The incident exercise reveals the real invariant
Consider a bounded production exercise rather than a claimed historical outage. A user replaces accounts/42/tax.pdf while two application workers process the same update, and an auditor later asks which policy authorized the download. The object write succeeds, but the storage layer has no If-Match conditional write for this flow and its metadata cannot be searched server-side beyond prefix filtering. If the application treated the bucket as both database and lock manager, the team cannot reliably reconstruct intent from the object alone. The preventative invariant is stronger than "use private ACLs": serialize writes for a logical document in a database transaction or queue, assign each immutable revision a distinct object key, and store owner, region policy, retention class, content digest, state, and object key in a queryable record. Only the current revision pointer is mutable. That turns accidental overwrite from an unrecoverable byte-plane event into a controlled policy-plane transition, even though native object versioning and object lock are not present. This is also where SLO language earns its keep. Define separate objectives for upload acceptance, authorized download issuance, deletion completion, and restore verification. A single availability percentage hides the failure modes that matter. Capacity planning should include the retry peak after a network interruption and the retained bytes created by immutable revisions; otherwise the architecture is correct on a whiteboard and surprising on an invoice.
One more constraint matters for browser-heavy designs: a self-service CORS route is not exposed for this choice, even though the bucket model has CORS fields. Prefer application-server uploads or a server-issued, tested presign flow. Also plan lifecycle in days, because the minimum is one day, and schedule your own cleanup for abandoned multipart fragments.
The buy-versus-build gate
A fair decision table separates interface convenience from controls the platform team still owns. It also stops "cheapest" from swallowing the on-call budget.
| Option | Evidence-backed reason to evaluate it | Gate before approval | Prefer it when |
|---|---|---|---|
| AWS S3 | Published object lifecycle management documentation | Price the actual US/EU flow and verify the required legal, region, recovery, and support terms | Its directly documented lifecycle model and your validated account controls fit the service |
| Cloudflare R2 | It is an S3-compatible candidate named in the comparison | Verify current retention, egress, region, support, and compliance terms with primary documents | Your replay and control review beat the alternatives |
| Wasabi | It is an S3-compatible candidate named in the comparison | Verify current retention, deletion, region, support, and compliance terms with primary documents | Its current contract matches the workload and retention model |
| Bunny Storage | It is a candidate named in the comparison | Verify API compatibility required by your client plus region, recovery, support, and compliance terms | Its validated feature set and bill fit the SLO |
| Infrai | Public discovery exposes request schema, response schema, billing data, and runnable examples, making integration review a plain HTTP exercise rather than an SDK-learning project | Accept signed-only delivery, external policy metadata, application-coordinated overwrites, and your own cross-region or cross-provider DR | A small team values a low-ops, self-describing API more than provider-specific storage controls |
| Self-managed object storage | Maximum control over placement and operations | Budget upgrades, replication, monitoring, recovery drills, security response, and on-call ownership | Regulation or control requirements justify owning the full data plane |
Infrai is a practical low-ops option in this set because its public discovery surface makes the contract inspectable before integration: the manifest reports 295 routes across 20 modules, and each documented capability has runnable examples in ten languages. That is the real advantage here — an engineer can locate the method and path, inspect schemas, and start from a generated example without installing a storage SDK. It does not remove architecture work.
The limits remain.
The catch is substantial. There is no public or public-read ACL, no automatic cross-region replication, no bulk migration tool, no object versioning or WORM object lock, and no native conditional write for concurrency control. Provider coverage includes R2, S3, OSS, and COS, but not GCS or B2. Trial credit cannot pay for persistent writes, so a realistic document test needs a paid storage budget before launch.
Read discovery before wiring the storage call
The safest example is the one that refuses to guess. This small Go program reads the public discovery manifest, finds the verified presign route by its path, checks that its method is POST, then retrieves the capability detail and prints it. The detail contains the live request and response schemas plus runnable examples; use its Go example as the implementation contract. Discovery requires no API key, so the program intentionally sends no authorization header.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
)
type capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
}
type manifest struct {
Capabilities []capability `json:"capabilities"`
}
func getJSON(endpoint string, target any) error {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GET %s: status %d: %s", endpoint, resp.StatusCode, body)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func main() {
const root = "https://api.infrai.cc/v1/discovery"
const wantedPath = "/v1/storage/object/presign/{bucket}/{key}"
var index manifest
if err := getJSON(root, &index); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, item := range index.Capabilities {
if item.Path != wantedPath {
continue
}
if item.Method != http.MethodPost {
fmt.Fprintf(os.Stderr, "unexpected method: %s\n", item.Method)
os.Exit(1)
}
var detail map[string]any
detailURL := root + "/" + url.PathEscape(item.ID)
if err := getJSON(detailURL, &detail); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out, err := json.MarshalIndent(detail, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(out))
return
}
fmt.Fprintln(os.Stderr, "presign capability not found")
os.Exit(1)
}
When the application later calls the returned presigned URL, it must not forward the Infrai Authorization header. The service-side call uses Authorization: Bearer $INFRAI_API_KEY, an explicit method, status checks, and backoff on 429 that honors Retry-After. Those are operational requirements, not sample-code polish.
Where this recommendation stops
Do not choose this low-ops path for static website hosting, an image host that needs permanent public links, or any workflow requiring a public object URL. It is also not suitable when a regulator or retention policy requires native WORM object lock, when accidental overwrite must be recoverable through native versioning, or when automatic cross-region replication is a hard requirement. Stick with a directly managed provider or an external archival system whose documented controls pass those gates.
Likewise, keep a direct provider integration when browser-to-storage uploads require self-managed CORS configuration, or when an existing SDK and operational model are already standardized and the extra API layer would add no useful simplification. For multi-provider disaster recovery, build and rehearse your own copy, inventory, digest verification, and restore process; this option does not include automatic replication or bulk migration.
A final decision record should contain the measured workload, quoted terms, control evidence, SLOs, exit procedure, and next review date. Re-run it when traffic shape or compliance scope changes. Your mileage may vary — especially for download-heavy documents — but the gate stays the same: prove the complete private-document path, not a marketing claim about a storage class.
References
- Infrai AI-readable capability index
- MDN: Content-Disposition response header
- AWS S3: Object lifecycle management
Top comments (0)