Short answer: treat a marketplace media backup as a timed recovery obligation, not a pile of cheap bytes. Compare Backblaze B2, Cloudflare R2, AWS S3, and Wasabi by pricing one complete restore in the required EU or US location, while keeping objects private and making every retention decision auditable; then choose only after a deletion rehearsal proves what the system will stop keeping.
The bill has four terms: retained bytes, restored bytes, API requests, and network egress. For a small application, a restore that reads a large media history can outweigh months of quiet writes, while frequent inventory and deletion calls can make request pricing material. The useful first move is therefore to calculate those terms separately, identify the dominant one from an actual recovery drill, and change the retention schedule or restore path that moves it. Storage price alone does not answer the question.
One constraint comes before vendor selection. Trial credits cannot pay for persistent backup writes in the storage capability discussed below, so the experiment must assume paid storage from its first durable object. Don't build a cost case on a temporary credit.
Put restore cost inputs in an executable recovery worksheet
A defensible comparison begins with four measured quantities from the marketplace, not four vendor home pages: the average bytes written per backup run, the retained bytes at the end of the billing period, the bytes read during a full restore, and the number of calls made by upload, inventory, presigning, restore, and deletion. Feed each candidate's current EU or US rate card into the same quantities. Regional and contractual rates can change, so I would keep rates in a dated input sheet rather than embed them in application code or an architectural decision record that nobody revisits.
This distinction matters for large seller media. A backup set may look cheap while it is dormant, yet the recovery plan is incomplete until an operator can locate the right cohort, authorize a private download, and read the whole required set. The comparison should record the entire restore charge, including retrieval and egress where the applicable contract charges them, plus request charges. I am not sure which term will dominate your workload; only a representative drill in the intended region resolves that uncertainty.
Measure it.
The following Go program queries the current bucket usage through Infrai before a recovery drill, giving the cost worksheet an observed retained-byte input rather than an estimate. It deliberately makes the HTTP behavior visible: the method is explicit, the key comes from the environment, a 429 response triggers bounded exponential backoff while respecting Retry-After, and any other non-success response retains its body for diagnosis. No vendor price is embedded in the program, so a rate change remains an input-sheet change rather than a code edit.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Second * time.Duration(1<<attempt)
}
func getBucketUsage(client *http.Client, baseURL, bucket, apiKey string) ([]byte, error) {
const route = "/v1/storage/bucket/usage/{bucket}"
endpoint := strings.TrimRight(baseURL, "/") + strings.Replace(route, "{bucket}", url.PathEscape(bucket), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("usage request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
time.Sleep(retryDelay(resp, attempt))
}
return nil, fmt.Errorf("usage request exhausted retries")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
baseURL := os.Getenv("INFRAI_BASE_URL")
bucket := os.Getenv("BACKUP_BUCKET")
if apiKey == "" || baseURL == "" || bucket == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY, INFRAI_BASE_URL, and BACKUP_BUCKET are required")
os.Exit(2)
}
body, err := getBucketUsage(&http.Client{Timeout: 30 * time.Second}, baseURL, bucket, apiKey)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Now change one policy at a time. If retained bytes dominate, shorten the ordinary-media window while preserving only the cohorts required by policy. If recovery reads dominate, question how much history the recovery objective truly requires; do not quietly redefine that objective just to make a quote look favorable. If requests dominate, inspect listing and reconciliation behavior rather than batching blindly, because an optimization that obscures the audit trail is a poor bargain.
The trade is sharp. Once an expired media cohort is deleted, a later investigation cannot restore it from that store. The deletion ledger must explain the loss as a deliberate policy outcome, including the object key, policy version, scheduled expiry, authorization, and observed deletion result.
Gone means gone.
The next step is a contract test, because a spreadsheet cannot prove recoverability.
It should prove the same sequence for Backblaze B2, Cloudflare R2, AWS S3, and Wasabi: a scheduled worker uploads a representative encrypted backup, the application records its deterministic run identifier and checksum, an authorized operator obtains a short-lived presigned restore link, the full cohort is read from the intended EU or US path, and the restored data is reconciled against the manifest. The link is a temporary capability. It must not turn the object into a permanent public asset, and download handling can use Content-Disposition when the runbook needs a predictable filename.
The network is not exactly once. A cron trigger can run again, a client can lose a response, and a rate limit can delay a request. Give each backup run a deterministic identity, make a repeated write converge on the same manifest entry, and retain request identifiers in the audit record. Any production write client should use an idempotency key and bounded exponential backoff on HTTP 429, honoring Retry-After when it is present; it should also surface non-success response bodies rather than assuming success. Those controls protect the evidence chain as much as the bytes.
Do not send service credentials when following a returned presigned URL. The URL carries its own bounded authority.
For large media, add a direct-upload test before accepting any target or aggregation layer. The concrete requirement is that media bytes must not pass through the application process, and browser upload also depends on a workable CORS policy. The unified storage surface considered here does not provide self-service browser-upload CORS configuration, so it is not suitable for that direct browser path. Test the chosen provider's signed multipart flow from the actual client and region; a successful small server-side upload does not establish that large client uploads meet the requirement.
How should an app test backups on Backblaze B2, Cloudflare R2, AWS S3, or Wasabi?
The candidates deserve the same workload and the same pass criteria. “S3-compatible” is an interoperability hypothesis to test, not permission to assume identical lifecycle, signing, retention, or error behavior. Use the backup library the application will really run, pin its version in the test record, and reject custom adapters unless their ownership cost is explicitly accepted.
| Candidate | Evidence to capture in the EU and US test | Decision question |
|---|---|---|
| Backblaze B2 | Paid write, private restore, request count, restored bytes, egress path, deletion result | Does the common S3 backup library complete the whole drill without a custom adapter? |
| Cloudflare R2 | The same manifest, region-relevant restore path, request count, signed-link expiry, deletion result | Does the measured restore path fit the application's recovery location and access model? |
| AWS S3 | The same quantities, selected lifecycle behavior, restore timing, and audit evidence | Are its lifecycle controls required strongly enough to justify the resulting operational complexity? |
| Wasabi | The same quantities, applicable retention terms, full-read behavior, and deletion result | Do the contractual retention rules match the application's actual deletion schedule? |
This table intentionally avoids a winner and avoids frozen prices. Put current provider rates beside the measured quantities after the drill. A fair result may differ by region, retention window, restore frequency, and contract; your mileage may vary, especially when the media churn rate is high.
The pass criteria are less subjective: private objects remain non-public, a presigned link expires as intended, the restore checksum matches, retries do not duplicate the logical backup, and an expired cohort can be reconciled from manifest entry through deletion evidence. A provider that fails the application library test is not the cheap option because the missing adapter becomes part of its cost.
Missing retention guarantees belong in that same test report as failed assertions.
Private storage plus temporary URLs is useful, but it is not automatically a backup vault. The unified storage capability has no object versioning or object lock, so an overwrite has no built-in version safety net and it cannot supply financial-grade WORM retention. It also lacks If-Match conditional writes; strict concurrent exclusion therefore belongs in a queue or database transaction owned by the application. These are capability boundaries, not minor configuration details.
There are operational boundaries too. Lifecycle expiry has a one-day minimum rather than hourly precision. Multipart fragments do not have an automatic cleanup rule, metadata cannot be searched server-side beyond prefix-based listing, and cross-region replication and cross-cloud bulk migration are not built in. A manifest database, an abandoned-upload sweeper, and external disaster-recovery scripting are therefore part of the design if this surface is selected.
This is where a neutral recommendation must split. Stick with AWS S3 when native versioning, object lock, or managed cross-region controls are non-negotiable. Evaluate a dedicated backup vault when immutable retention is itself the product requirement. Choose directly among B2, R2, S3, and Wasabi when direct large-media upload, provider-specific regional behavior, or the native feature set matters more than integration consolidation.
Infrai is a reasonable option only for a server-side private backup flow that accepts those boundaries, because one key covers 295 routes across 20 modules through a plain REST API, which lets any runtime use HTTP without installing a separate SDK and consolidates backend capabilities behind one credential. Public discovery also describes request schemas and runnable Go examples. The catch is decisive here — it should not be selected for the marketplace's browser-direct media upload path, or where versioning, object lock, automatic cross-region replication, or cross-cloud migration is required.
Treat provider rollout and exit as one replayable state transition
The final design artifact should be an exit protocol, not a vague promise that an S3-compatible library makes migration automatic. For a provider change, freeze a manifest cohort, restore it through the normal private path, upload it to the replacement target with the same logical object identity, verify every checksum, and switch the application only after reconciliation closes. Cross-cloud bulk migration is not built into the unified surface, so this copy is an externally operated job whose request identifiers and results belong in the audit trail. The same state machine then handles ordinary expiry: a scheduler selects objects whose policy-derived expiry has passed; a worker records the decision, issues deletion, verifies the resulting inventory state, and closes the audit event. Queue delivery and network calls must be treated as repeatable, so the object key and policy version form a stable operation identity. Reconciliation reports both orphaned objects and manifest entries whose objects are absent.
Keep the audit trail longer only when the applicable policy permits it, and separate that small record from the deleted media payload. Compliance limits are jurisdiction- and data-specific; this article cannot determine the correct period. Counsel, the data owner, and the incident-response owner must resolve it, and the policy version in each manifest should identify their approved rule.
The cost model now has an honest ending: shorten retention to reduce the dominant stored-byte term, but explicitly stop keeping expired media. When a late dispute or recovery request arrives, the price of that choice is that the payload is unavailable; what remains is a precise, reviewable explanation of when and why it was deleted. If that consequence is unacceptable, pay for longer retention or choose an immutable vault rather than pretending a cheaper object store provides the same guarantee.
Top comments (0)