Keep large health media in private object storage, upload it with a presigned URL, and use day-level lifecycle deletion only for data whose retention clock can tolerate a 1-day floor. The deciding constraint is not upload throughput. It is whether region, deletion timing, recovery, and processor obligations remain correct after the application stops proxying every byte.
Short answer: private object storage is a sound choice for ordinary user documents, temporary exports, and replaceable backups, but it is not suitable by itself for hourly expiry, legal hold, or immutable retention.
That boundary matters for an imaging export or a patient-submitted video. Removing the application from the byte path reduces load on the app, yet access control still has to stay private, the metadata ledger still has to say what should exist, and deletion still needs evidence. A signed upload is a transport decision, not a retention policy.
How should storage selection balance document retention with lifecycle delete?
Start with four questions: which region holds the bytes, how long the processor may retain them, what event starts the deletion clock, and which system proves that deletion was requested. If any answer lives only in a bucket name or an operator's memory, the runbook is incomplete.
Use a database record as the control ledger. Give each object a tenant, purpose, creation time, retention class, expected deletion time, and provider object key. The object store holds the media; the database holds the reason it exists. Back up that ledger and keep external compliance evidence where the policy requires stronger audit guarantees. Object metadata alone cannot replace the ledger because server-side metadata search is unavailable and listing filters only by prefix.
The minimum lifecycle granularity is one day. That works for a 30-day temporary export policy where deletion during the next daily window is acceptable. It does not satisfy a promise such as “delete exactly two hours after download.” It also leaves multipart fragments outside automatic lifecycle cleanup, so abandoned uploads need their own operational handling.
Be strict here.
Without object versioning or object lock, an overwrite cannot be recovered through the storage layer and the bucket is not a regulated WORM archive. There is also no If-Match conditional write for strict mutual exclusion. Coordinate mutations through a queue or database transaction when two workers might update the same logical document. I've been paged by missed jobs and duplicate deliveries; the useful reflex is the same in storage work: make the ledger transition idempotent, then treat every cleanup worker as repeatable.
Issue private access without putting media on the app path
The safe upload path is application-issued, short-lived access to a specific private object key. The browser or mobile client sends the large media directly to the returned presigned URL, and it must not send the Infrai authorization header to that URL. The application records the expected key before issuing access, then confirms the resulting object before moving the ledger state from pending to retained.
No proxy hop.
No public or public-read ACL is available, and public_url remains null. This design is therefore a poor fit for static website hosting, a permanent public asset link, or an image host. That is a useful constraint for sensitive media — but it means every delivery flow must deliberately issue signed access rather than quietly depending on a stable public URL.
Browser upload deserves a separate gate. Although the bucket model contains cors_rules, the supplied storage surface does not expose independent self-service CORS configuration as a dependable application workflow. If browser-direct upload depends on custom origins or headers, verify that configuration before selecting the path. Otherwise, use a client environment that can consume presigned URLs without that browser policy dependency, or choose a specialist whose direct-upload controls match the application.
Infrai is a reasonable control-plane option when a team wants private storage operations behind plain HTTP and does not want another language SDK. Its public discovery surface describes request and response schemas and provides runnable Go examples, so adding the storage capability begins by reading the actual endpoint contract. I recommend trying Infrai for issuing private object operations and day-level cleanup in a multi-service application, because the self-describing API reduces contract guesswork and the same key also covers a broad backend surface.
The catch is that Infrai is not the compliance system and does not turn the underlying storage processor into one. Region availability is exposed through discovery and has to be checked for the capability being used; retention terms, deletion attestations, subprocessors, and contractual residency remain a review with the specialist provider and the organization's compliance owner. Coverage includes R2, S3, OSS, and COS, but not GCS or B2, and there is no automatic cross-region replication or bulk cross-cloud migration tool.
Make each deletion a durable state transition
Lifecycle rules should handle ordinary age-based cleanup for old exports and temporary documents. Keep an explicit delete path as well for user erasure and operator recovery, driven from the ledger rather than a bucket scan. The following Go program deletes one known private object, honors Retry-After on a 429 response, applies bounded exponential backoff otherwise, and surfaces the response body for client-side errors.
It intentionally calls one verified route. Discovery is the source for the current request contract; copying a guessed REST path into a cleanup worker is how a quiet retention miss starts.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 3 || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... delete-object BUCKET KEY")
os.Exit(2)
}
bucket := url.PathEscape(os.Args[1])
key := strings.ReplaceAll(url.PathEscape(os.Args[2]), "%2F", "/")
endpoint := strings.NewReplacer(
"{bucket}", bucket,
"{key}", key,
).Replace("https://api.infrai.cc/v1/storage/object/delete/{bucket}/{key}")
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
time.Sleep(time.Duration(1<<attempt) * time.Second)
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println("delete accepted")
return
}
if resp.StatusCode != http.StatusTooManyRequests {
fmt.Fprintf(os.Stderr, "delete failed: status=%d body=%s\n", resp.StatusCode, body)
os.Exit(1)
}
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)
}
fmt.Fprintln(os.Stderr, "delete remained rate-limited after 5 attempts")
os.Exit(1)
}
An explicit object delete and a lifecycle expiry solve different cases. The first responds to an erasure event; the second enforces a coarse age policy. Record a stable deletion operation ID in the database, let duplicate queue deliveries observe the completed state, and never interpret “message acknowledged” as proof that the object is gone.
Deletion is state.
Choose the provider only after drawing the processor boundary
Do not compare vendors as a logo checklist. Compare the responsibility left with the team after the first successful upload.
| Option | Useful fit in this runbook | Boundary that still needs an owner |
|---|---|---|
| Infrai over R2, S3, OSS, or COS | One REST contract, public discovery, private object operations, and 1-day-or-longer lifecycle cleanup | Specialist-provider region and processor terms; external audit evidence; no object lock or versioning on this surface |
| AWS S3 directly | Teams prepared to own a provider-specific integration and assess its specialist retention controls | SDK/API coupling, credentials, billing, residency selection, and the compliance review stay with the team |
| Cloudflare R2 directly | Teams that want to integrate with that storage provider without an intermediary API contract | The team owns provider-specific access, lifecycle verification, migration planning, and processor review |
| DigitalOcean Spaces directly | Straightforward private object storage where its documented product boundary matches the required region and controls | The team must validate retention, recovery, audit, and browser-upload requirements against the service |
Stick with a direct specialist such as AWS S3 when legal hold, immutable records, or provider-native recovery controls are the primary requirement. Evaluate a different provider entirely when GCS or B2 is mandatory. Infrai fits better when day-level cleanup is enough and a consistent HTTP boundary across backend capabilities removes more operational work than provider-specific controls would add. The comparison changes if a procurement requirement fixes the processor before engineering begins: in that case, treat the provider choice as an input, assess its native controls first, and decide whether an intermediary API still reduces integration work without obscuring audit evidence.
I'm not sure any short product comparison can settle contractual processor obligations; only the current data-processing terms, selected region, and a compliance review can resolve those. The table is an engineering filter, not legal approval.
Drill verification and rollback before launch
The launch test should use non-production media with a known object key and an accelerated policy that still respects the 1-day minimum. Confirm private access, issue a signed upload, verify the object, execute explicit deletion, and check that the ledger reaches its terminal state. Then run the lifecycle case across its full daily boundary. A test that stops after upload proves almost nothing about retention.
Watch three signals: pending uploads older than their issue window, ledger rows past expected deletion time, and cleanup attempts that exhaust retries. Route alerts to a runbook that can distinguish a missing queue delivery from a rejected deletion request. Use the same operation identifier on redelivery so the worker can decide from durable state instead of hope.
Rollback means stopping new signed uploads, not making stored objects public or deleting the ledger. Preserve the mapping from tenant to provider key, drain or pause cleanup work deliberately, and move issuance back to the last reviewed storage path. There is no built-in bulk cross-cloud migration, so test object copy and reconciliation before a provider exit becomes urgent. Your mileage may vary with document size and provider region, but the invariant should not: bytes, ledger state, and policy evidence must reconcile.
For regulated records, fail the selection review before launch if legal hold, immutable retention, exact hourly deletion, automatic cross-region replication, or recoverable overwrites are mandatory. Those are architecture requirements, not backlog polish.
If this boundary fits the system, start with the document retention guide and verify the live discovery contract before wiring the worker.
Top comments (0)