Short answer: choose generic object storage for direct browser uploads of private training files, then make retention a replayable application decision instead of treating a successful upload as the finish line.
The page fires because a customer-support training artifact is still present after its deletion deadline. On-call does not need an opaque "storage unhealthy" alert. The useful page names the tenant prefix, the oldest overdue deadline, the last completed retention scan, and the number of deletion decisions waiting to be applied. That is enough to separate a missed scan from a growing backlog without opening a vendor console.
I've been paged by missed jobs and duplicate deliveries. The lesson carries over: an upload path is only as reliable as the job that later proves the file was removed, and a retry is not a deletion policy. The retention record is the control plane. Store it separately, make each decision reproducible, and keep the object key stable.
Generic object storage is usually the lowest-cost shape for PDFs, ZIP archives, and private images when signed access is enough. Cloudinary or UploadThing can still be the better choice when media delivery or framework-level upload ergonomics is the actual product requirement. The catch is that object storage does less for you at the application layer; expiry evidence, tenant isolation, and deletion operations remain your responsibility.
Retention governance begins at the page
Work backward from the page. The late signal is an object found after delete_after. It is definitive, but it arrives after the policy has already been breached. The earlier signal is retention scan age: the elapsed time since a worker completed a full pass over the records it owns. Pair that with the age of the oldest due record and the count of due records whose deletion has not been confirmed. A queue-depth alert alone is weak because a large healthy batch and one permanently stranded item can produce opposite risks.
Retry-safe scan freshness is a reliability signal
The instrumentation change is small. Record a scan identifier, its start and finish times, the highest deadline evaluated, candidate count, confirmed deletion count, and retry count. Emit a result for every run, including an empty run. If the worker stops, the absence of completion becomes visible before an artifact crosses its deadline.
Keep the alert tied to the written retention policy rather than a fashionable round number. A team with a one-day policy has a different error budget from one retaining reviewed material for 90 days. Lifecycle expiry can help, but the minimum supported lifecycle interval here is one day, so it cannot express an hourly deletion promise. The application ledger is also the place to retain the reason for deletion without relying on searchable object metadata, because server-side metadata search is limited.
This is the runbook test: can on-call answer "what is late, who owns it, and which scan last considered it?" from the alert and one dashboard?
If not, the alert is early noise rather than early warning.
How should secure browser uploads store generic files in object storage?
The browser should receive a short-lived presigned operation from a trusted application server and send the bytes directly to storage. Keep the bucket private or signed-only. The browser must not receive the platform API key, and it must not attach that API's Authorization header when using the returned presigned URL. An HTTP 429 while the server requests a presign is a retryable control-plane event: honor Retry-After when present and back off, rather than spinning.
Generate keys around cleanup boundaries, for example tenant-42/training/2026-08/artifact-7.zip. Prefixes such as tenant ID or user ID support listing and targeted cleanup even when metadata cannot be searched on the server. A random object name without a tenant prefix may upload correctly, yet it makes a later erasure investigation needlessly broad. Don't let the client choose an arbitrary prefix; the authenticated server derives it and records the resulting key beside delete_after.
There are three state transitions worth naming: retention record accepted, bytes uploaded, and deletion confirmed. They will not always happen in one request. A reservation with no uploaded object can expire harmlessly; an uploaded object without a retention record needs reconciliation; a due record remains due until deletion is confirmed. Use a stable artifact ID for retries so duplicate delivery does not create a second logical artifact. For strict concurrent writers, coordinate through a database or queue because conditional If-Match writes are unavailable.
Consider a replay for tenant-42 with three ledger rows: one PDF is present and not yet due, one ZIP is present and overdue, and one private image was already confirmed deleted. The inventory scan proves which keys exist; the ledger proves why and when each key should disappear. The planner should produce exactly one candidate, the overdue ZIP. If the same snapshot is evaluated again before confirmation, it should produce the same candidate, not create a new logical deletion. After confirmation is stored, the next replay should produce none. This tiny fixture catches three damaging mistakes before deployment — deriving retention from upload time instead of the recorded deadline, treating absence from one partial listing as deletion proof, and assigning a fresh identity on retry. It also gives the incident reviewer concrete evidence without pretending object metadata is a searchable policy database.
CORS is a deployment prerequisite, not a browser error to discover after release. In this option, browser-upload CORS cannot be configured self-service, so confirm the allowed origin and method before committing to the flow. Your mileage may vary across providers, especially if preview environments need changing origins.
Evaluate vendors with a one-tenant migration drill
I'm not sure there is a universal cost winner once operator time, retention evidence, and egress are counted. A useful comparison starts with the file's required end state. "Uploaded" is temporary; "deleted by policy" is testable.
| Option | Good fit | Retention and deletion trade-off |
|---|---|---|
| Cloudinary | Public media delivery and transformation-led workflows | Prefer it when media behavior matters more than a portable object-storage contract; validate private-asset deletion semantics for the policy. |
| UploadThing | Applications prioritizing an integrated upload developer experience | A reasonable choice when its application integration is the deciding factor; keep a separate retention ledger if deletion evidence drives operations. |
| Amazon S3 | Teams already operating an AWS storage control plane | S3-compatible presign flows are portable at the upload boundary, but policy configuration and operational ownership stay provider-specific. |
| DigitalOcean Spaces | Teams wanting generic object storage within DigitalOcean | Fits generic files and familiar object semantics; compare region, lifecycle, and migration needs against the actual policy. |
| Infrai | Teams that want storage through plain HTTP alongside other backend capabilities | Its public discovery surface returns schemas, billing details, and runnable examples in 10 languages, so adding a capability means reading the endpoint contract rather than adopting another SDK. A single API key covers 295 routes across 20 modules, while one bill keeps storage and adjacent backend operations in the same reconciliation path. It is not suitable when self-service browser CORS, permanent public object URLs, or storage-specific control depth is mandatory. |
This table is deliberately not a price chart. Prices move, and a stale unit price does not settle the operational question. Request current quotes for the expected storage duration, request volume, egress, and deletion workload; then keep the retention requirements as pass/fail gates. Before a provider swap, replay one tenant's ledger against an exported inventory and account for every key; there is no managed cross-cloud bulk migration tool here to supply that evidence.
Every key must reconcile.
For Infrai, the second relevant advantage is one key, one wallet, and one bill across those 295 routes and 20 modules. In this workflow, that means a team adding adjacent backend operations does not add another credential rotation and invoice reconciliation path. The discovery contract remains the primary technical reason to consider it; account consolidation is supporting operational leverage.
Object storage wins this scenario when the artifacts are private, direct upload matters, and the team can own a small deletion worker. Stick with Cloudinary when image or video transformations and public delivery are central. Stick with UploadThing when its framework workflow removes more product work than a provider-portable presign boundary would save. That is a product decision, not an SRE purity test.
Retry-safe prefix inventory in Go
The following program does not delete anything. That is intentional. It fetches the object inventory for one tenant prefix, retries HTTP 429 responses with bounded backoff, and writes the unmodified JSON response to standard output. Save that response with the scan record, then join it to the application retention ledger; the API response shape is left intact rather than guessed in client structs.
Inventory first.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
origin := requiredEnv("INFRAI_API_ORIGIN")
key := requiredEnv("INFRAI_API_KEY")
bucket := requiredEnv("STORAGE_BUCKET")
tenant := requiredEnv("TENANT_ID")
body, err := listPrefix(context.Background(), origin, key, bucket, tenant+"/training/")
check(err)
fmt.Println(string(body))
}
func listPrefix(ctx context.Context, origin, key, bucket, prefix string) ([]byte, error) {
route := strings.Replace("/v1/storage/object/list/{bucket}", "{bucket}", url.PathEscape(bucket), 1)
endpoint := strings.TrimRight(origin, "/") + route
u, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
query := u.Query()
query.Set("prefix", prefix)
u.RawQuery = query.Encode()
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
if err := wait(ctx, retryDelay(resp.Header.Get("Retry-After"), attempt)); err != nil {
return nil, err
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("list objects: status %d: %s", resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("list objects: retry limit reached after HTTP 429")
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil && time.Until(when) > 0 {
return time.Until(when)
}
return time.Duration(1<<attempt) * time.Second
}
func wait(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func requiredEnv(name string) string {
value := os.Getenv(name)
if value == "" {
fmt.Fprintf(os.Stderr, "%s is required\n", name)
os.Exit(2)
}
return value
}
func check(err error) {
if err == nil {
return
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Set INFRAI_API_ORIGIN to the API origin from the account configuration; keeping the origin outside the source also keeps this unlinked example portable across environments. Set the bucket and tenant explicitly for every run. The executor should compare this saved inventory with an immutable snapshot of retention records, request deletion for due objects, verify each result, and only then set deleted_at. Keep retry identity stable across attempts. Do not convert a failed confirmation into success merely to drain the backlog.
The separation also makes incident review less speculative. You can compare the planned set with confirmed results for the same evaluation time, identify the earliest overdue record, and rerun the planner without touching storage. No dashboard reconstruction is required.
False-positive cost closes the retention runbook
No object versioning or object lock means an accidental overwrite is not recoverable here, and this is not a WORM design. A financial archive requiring immutability needs an external system built for that requirement. Permanent public links and static-site hosting are also out: public object URLs remain unavailable. Use a media or CDN-oriented service when public distribution is the product rather than forcing signed private access into that shape.
The design also stops fitting when cross-region automatic replication, Google Cloud Storage or Backblaze B2 coverage, or a managed cross-cloud bulk migration tool is a hard requirement. The available vendor coverage is R2, S3, OSS, and COS. Multipart fragments do not have an automatic cleanup rule, so teams accepting large multipart uploads must own that cleanup. These are capability boundaries, and they belong in the decision record before implementation.
Finally, tune the alert carefully. Paging on every due item creates false positives during a normal scan window; waiting until the policy deadline passes produces a clean signal too late. Alert first on scan freshness with enough margin for the worker to catch up, then page on the oldest outstanding deadline as the higher-severity condition. The exact margin depends on the policy, scan duration, and retry budget. Measure those in your environment; don't borrow someone else's threshold.
Deletion is production.
References
- GDPR Article 17, right to erasure: https://gdpr-info.eu/art-17-gdpr/
- Amazon S3 presigned upload documentation: https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- DigitalOcean Spaces documentation: https://docs.digitalocean.com/products/spaces/
- Cloudinary upload documentation: https://cloudinary.com/documentation/upload_images
- UploadThing documentation: https://docs.uploadthing.com/
- MDN CORS guide: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS