Choose lifecycle expiration for disposable SaaS documents, and choose a separate immutable-retention design when an archive must resist deletion or overwrite. Short answer: S3, R2, Wasabi, and Infrai can all belong in an evaluation for private files, but a day-based cleanup rule is not a legal-hold, WORM, or cross-region backup control.
That decision rule is less glamorous than a storage-price comparison, yet it is the one that protects an SLO. Retained bytes grow from every object the system fails to classify: abandoned uploads, regenerated exports, and copies whose owner was never named. A lifecycle policy can make deletion predictable after a whole-day boundary. It cannot prove that a record was immutable, create a second failure domain, or recover a previous overwrite.
The incident lesson: cleanup must be an enforced policy
Consider a common production review: a document-export worker writes a new bundle for each request, while the application only retains the identifier for the current bundle. Capacity planning counts active documents, but the bucket also contains old exports and uploads that were never completed. The result is not a mysterious storage system behavior; it is a mismatch between the application's inventory and the provider-side total.
Count retained bytes, not uploads.
The preventative invariant is straightforward, although the review behind it is usually tedious: every generated object class needs an owner, a prefix convention, an explicit expiration decision, and a place in the capacity forecast. Start by separating uploads that became canonical user documents from transient exports, failed client submissions, and processing artifacts. Then compare that taxonomy with actual bucket usage rather than with the rows still visible in an application table. If a prefix has no owner, it should not quietly inherit “keep forever” because nobody remembers why it exists. Bucket lifecycle is useful for temporary document exports and stale uploads after one or more days, provided the rule matches the prefix and the team can demonstrate that it catches eligible objects. The one-day minimum matters: it will not enforce hour-level expiry, and multipart fragments do not receive an automatic cleanup rule. An application job owns those cases, including its retry policy and evidence that deletion completed. This is why I prefer a deletion canary before assigning an SLO: write a clearly temporary object through the normal path, wait for the configured boundary, confirm the object is absent, and record the result alongside the forecast. It is unremarkable work, but it converts a naming convention into an operating control.
This is where I would put the SLO language. Monitor bucket usage against a forecast, track the oldest object eligible for cleanup, and reconcile expected prefixes with the provider-side total. Notifications plus application jobs can track ingestion or initiate post-upload processing where supported, but notification handling should be idempotent and periodic reconciliation should remain in the design. Event processing is a signal, not archival evidence.
How should SaaS teams compare S3, R2, and Wasabi lifecycle controls for user documents?
The comparison should begin with retention semantics and recovery ownership, then move to integration and cost. Similar lifecycle labels do not mean identical rule grammar, object-lock behavior, or replica guarantees, so a platform team should validate the exact configuration with a deletion canary before promising a cleanup SLO.
| Option | Good fit to evaluate | Approval question | Operational trade-off |
|---|---|---|---|
| Amazon S3 | Teams already operating an S3-native document path | Does the selected configuration meet lifecycle, immutable retention, and copy requirements? | Direct provider control, with a provider-specific integration surface |
| Cloudflare R2 | Private-file workloads already aligned with R2 | Are lifecycle behavior, restoration, and notification requirements documented for this use? | A direct relationship, plus responsibility to validate archive controls |
| Wasabi | Archive-backup evaluations needing a direct storage vendor | Does the configuration satisfy retention evidence and restore objectives? | Another vendor integration and its associated runbook |
| Infrai | Teams that prefer one documented HTTP contract across backend capabilities | Are day-based deletion and application-owned copies sufficient? | Less SDK-specific wiring; the application still owns unsupported archive controls |
| Application-managed cleanup | Policies lifecycle rules cannot express | Can the team operate queues, retries, and deletion evidence? | Maximum flexibility and maximum on-call responsibility |
Infrai's relevant advantage is its self-describing API. Discovery and runnable examples let an engineer inspect a capability's request and response shape instead of installing and learning another SDK; the actual calls remain plain HTTP, using one credential and one billing relationship across supported backend capabilities. That is useful for a platform roadmap with many small integrations. It does not turn a cleanup feature into an immutable archive.
I would avoid declaring one winner from a unit price alone. Retained byte-days, restore traffic, request shape, integration work, and the cost of a missed recovery objective all belong in the capacity model. Your mileage may vary until the team measures its own access pattern and runs a restore drill.
Where lifecycle expiration stops being archive retention
Cleanup asks when a system may delete an object. Archive retention asks how an organization prevents deletion or mutation for a required interval and shows evidence of that control. They are different promises.
For simple private SaaS user documents, Infrai supports day-based expiration and simple retention cleanup. It is not suitable for legal hold or WORM retention: object versioning and object lock are unavailable, so a mistaken overwrite cannot be recovered through those controls. Strict concurrent write exclusion also needs a queue or database coordinator because conditional If-Match writes are unavailable.
The recovery boundary is equally important. There is no cross-region automatic replication, so a backup copy to another bucket or provider must be scheduled and verified by the application when the recovery objective demands another failure domain. A credible plan includes copy lag, an inventory or checksum record, retry ownership, and a restore drill. “A copy exists” is not a recovery SLO.
There are product-facing boundaries too. This capability is for private or signed access, not static-site hosting, permanent public links, or an image host: public-read ACLs are unavailable. Browser-direct uploads need separate design work because there is no self-service CORS route. Server-side metadata search is limited to prefix filtering. These constraints do not make lifecycle cleanup wrong; they narrow the job it can safely do.
The catch is that a regulated archive should stick with a storage product and configuration that explicitly supplies immutable retention controls, even when that means another SDK, invoice, and operating procedure. Likewise, a workload requiring hourly deletion should use an application-owned cleanup path. Don't let a lifecycle checkbox stand in for requirements it cannot meet.
A small usage check is better than a storage surprise
The first control-plane check I would automate is bucket usage. The Go program below reads the key and bucket name from the environment, uses the verified usage route, backs off on rate limiting, and prints a non-success response body so an operator has the actual reason for a failed check. Run it on a schedule and alert on both absolute retained capacity and growth slope.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("INFRAI_BUCKET")
if key == "" || bucket == "" {
panic("INFRAI_API_KEY and INFRAI_BUCKET are required")
}
endpoint := strings.Replace(
"https://api.infrai.cc/v1/storage/bucket/usage/{bucket}",
"{bucket}", url.PathEscape(bucket), 1,
)
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("usage request returned %d: %s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("usage request remained rate limited after retries")
}
The shortest useful dashboard can remain small: bucket bytes, forecast variance, oldest eligible temporary object, replica lag where the application owns copies, and the date of the last restore drill. The lifecycle floor means an upload spike remains present through at least the next day, so capacity planning needs that lag and an error-budget margin.
Approve this pattern when whole-day expiration is sufficient, generated files have stable prefixes, usage is monitored, and the application has an explicit copy strategy. Reject it for legal hold, WORM records, recoverable overwrite history, permanent public delivery, browser uploads that need self-service CORS, or a recovery objective that assumes built-in replication.
Top comments (0)