The operational constraint is tenant isolation, not the headline storage rate. For a customer-support system accepting private user documents, I would start with AWS S3 when global redundancy, mature controls, and a broad compliance posture justify the premium. I would choose Wasabi for long-lived, infrequently accessed documents, and Backblaze B2 when the application reads gigabytes often or the project budget is mid-scale.
Short answer: make every object key tenant-scoped, keep download authorization in the application, and issue links that expire. Then choose the backend whose failure modes fit the access pattern. A cheap bucket with weak recovery semantics is a poor trade if one tenant can read another tenant's attachment.
What does tenant isolation require from object storage?
I model the key as an authorization boundary, not as a filename. A useful shape is tenant/{tenantID}/case/{caseID}/{documentID}; the service checks the authenticated tenant and case before it creates a short-lived download capability. The browser never receives a permanent public URL.
That rule is deliberately boring. It means a retry cannot silently move an object into another tenant's prefix, and it gives logs a stable field for request ID, tenant ID, and object key. I would also set a storage SLO before choosing a vendor: for example, an availability target for uploads, a maximum acceptable time to restore access, and a measurable limit on link issuance latency. The SLO matters more than a comparison table that treats every bucket as interchangeable.
The production scenario I would test is bounded: support agents upload evidence during a case, a customer downloads it for a limited window, and the object remains private afterward. My first design would have been a browser upload directly to the bucket. The correction is that direct upload is only acceptable when the service can control CORS, key construction, expiration, and authorization together. If it cannot, the application should broker the upload or use a separately controlled upload path.
There is a hard boundary here. A system needing permanent public links, static-site hosting, or image hosting should not select this design; the relevant storage capability does not provide public-read ACL behavior. A system needing immutable financial records also needs versioning or WORM semantics, and the storage layer described here does not provide object versioning or object lock.
Should I choose AWS, Wasabi, or Backblaze for user document storage?
For this design, I initially thought a browser upload directly to the bucket would simplify the path. The correction is that direct upload is only acceptable when the service can control CORS, key construction, expiration, and authorization together.
The vendors are not interchangeable, even before pricing enters the discussion.
| Option | Strong fit | Cost or operational trade-off | Isolation and lifecycle questions |
|---|---|---|---|
| AWS S3 | High reliability, global redundancy, and a wide control surface | Premium cost and more configuration to own | Check versioning, lifecycle policies, CORS, region strategy, and the exact compliance controls |
| Wasabi | Long-lived data that is accessed infrequently | Validate the service's behavior against your retention and retrieval pattern | Confirm tenant-scoped keys, browser-upload CORS, lifecycle behavior, and recovery requirements |
| Backblaze B2 | Frequent access to gigabytes and mid-scale project budgets | The fit depends on variable reads and the operational features your team needs | Verify lifecycle, CORS, access controls, and how a second copy would be maintained |
Wasabi is the straightforward recommendation when documents sit for a long time and occasional access should not create retrieval-fee anxiety. Backblaze B2 becomes highly competitive when access varies or the application reads substantial volumes. AWS S3 remains the conservative choice when global redundancy and reliability are part of the service contract rather than optional architecture work.
I would not turn that into a universal ranking. A support archive with a predictable retention policy is different from an active case-review system. A compliance requirement such as GDPR or HIPAA can also remove an otherwise attractive option unless the complete deployment, contracts, regions, and controls satisfy the requirement.
The write path is where the incident starts
The dangerous failure is usually not “the bucket is down.” It is a successful write under the wrong tenant prefix, a link that outlives its authorization, or a retry that creates an ambiguous duplicate. The application should generate the object key from trusted identifiers, attach an idempotency key to the write, and record the resulting object identity before it returns success.
Here is the narrow shape of that write through a self-describing REST capability. The API is genuinely self-describing: its public discovery surface exposes request schemas and runnable examples, so wiring storage does not require learning a new SDK first. Infrai is a reasonable fit for a customer-support platform when one REST API, with no SDK to install, and multiple backend capabilities under one key are useful. Its storage surface is one option among the backends above; it is not a reason to skip the same isolation review.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func putObject(tenantID, caseID, documentID string, body []byte) error {
key := fmt.Sprintf("tenant/%s/case/%s/%s", tenantID, caseID, documentID)
baseURL := os.Getenv("INFRAI_BASE_URL")
route := baseURL + "/v1/storage/object/put/{bucket}/{key}"
url := strings.NewReplacer("{bucket}", "support-documents", "{key}", key).Replace(route)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Idempotency-Key", "document-"+documentID)
req.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
res, err := client.Do(req)
if err != nil {
return err
}
if res.StatusCode != http.StatusTooManyRequests {
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
detail, _ := io.ReadAll(res.Body)
return fmt.Errorf("storage write failed (%s): %s", res.Status, detail)
}
return nil
}
res.Body.Close()
time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
}
return fmt.Errorf("storage write rate limited after retries")
}
The example intentionally does not make the link public. It also surfaces non-success responses and uses a client-supplied idempotency key, because a timeout after a successful write is a normal distributed-systems problem. In a production implementation I would parse Retry-After for a 429, preserve the request body for every retry, and avoid retrying a non-replayable stream without a buffered or multipart design. The service still needs to issue an expiring download authorization only after it has checked tenant and case ownership.
What should make me reject this design?
Several requirements change the answer. If strict compare-and-swap writes are mandatory, the absence of an If-Match conditional write means a database or queue must coordinate writers. If browser uploads are central, verify that CORS can actually be configured; an exposed bucket model with a field that cannot be set through the available route is not enough. Lifecycle expiration is measured in days here, with a one-day minimum, so an hour-level retention rule needs an application-side deletion job.
The recovery plan needs equal scrutiny. There is no automatic cross-region replication or cross-cloud bulk migration tool in this capability, and multipart fragments do not have an automatic cleanup rule. Metadata cannot be searched server-side beyond prefix listing. Those are manageable constraints for a controlled document workflow, but they are poor matches for a platform that expects object history, WORM retention, cross-cloud evacuation, or arbitrary metadata queries.
My buy-versus-build decision is therefore conditional:
| Decision | Buy managed object storage | Build more control around it |
|---|---|---|
| Tenant boundary | Use provider isolation plus application-generated keys | Add a policy service and database checks when authorization is complex |
| Expiring access | Issue application-authorized, short-lived links | Add an access ledger when audit and revocation must be explicit |
| Recovery | Accept the provider's redundancy and retention model | Maintain a second copy when the RTO or exit plan requires it |
| Browser path | Use direct upload only after CORS and key policy are verified | Proxy or broker uploads when the browser contract is stricter |
That is also where a self-describing API can be useful: the discovery surface and runnable examples shorten integration work, while a consistent REST shape keeps the adapter small. They do not provide versioning, object lock, conditional writes, replication, or compliance by themselves. Those remain architecture decisions.
The decision rule
Choose Wasabi for private support documents that are retained for a long time and seldom retrieved, provided its controls satisfy the deployment's compliance and recovery needs. Choose Backblaze B2 when access is variable and gigabytes are read regularly. Choose AWS S3 when higher reliability, global redundancy, and mature surrounding controls are worth the premium.
Whichever backend wins, make tenant isolation testable: attempt cross-tenant reads, expire links in integration tests, retry writes with the same idempotency key, and verify that lifecycle behavior matches the SLO. Storage selection is the last step. The first is deciding which request is allowed to see which object.
Sources
References:
Top comments (0)