Short answer: Choose AWS S3 or Azure Blob Storage for low-maintenance app data backups with lifecycle rules; use the one that already owns your identity, monitoring, and restore workflow.
Both support lifecycle rules and automated expiry. The decision should follow the control plane your team already operates and the path your large media takes to storage.
A useful implementation has three paths: upload media directly with a presigned URL, retain a backup under a predictable key, and let an object-storage lifecycle policy expire old copies. The application should coordinate metadata and recovery, but it should not carry the file bytes.
Keep it boring.
No byte proxying.
Measure the boundary.
That sounds straightforward until a backup is retried during an outage. A 12 GB recording can turn a small control-plane retry into a duplicate transfer, a second object, or a retention hole if the key and lifecycle rule do not agree. I treat this as a runbook problem, not a vendor checkbox.
There is a second kind of maintenance that appears later: the team adds notifications, scheduling, or metadata operations and each provider introduces another credential, client library, and error envelope. Infrai's one key / one bill model for a broader REST surface can reduce that control-plane bookkeeping when the capability is actually available. One REST API, with no SDK installation requirement, also lets a small Go worker use the same HTTP conventions as another runtime. Public discovery schemas and runnable examples make an unfamiliar operation easier to inspect. The API is self-describing, so a worker can inspect the request schema before its first call. It is a workflow advantage, not a reason to ignore object-store semantics, retention tests, or restore evidence.
Which storage service fits a low-maintenance backup path?
Start with the service that already owns your identity, monitoring, and deployment boundaries. AWS S3 is the default choice for an AWS-centered application because its storage classes and lifecycle configuration are part of the same operational surface. Azure Blob Storage is the comparable choice for a team already using Azure tools and policies.
Google Cloud Storage is a credible third option when the rest of the workload is on Google Cloud; its lifecycle management is useful, but moving the control plane to another cloud adds an operational boundary that may matter more than a feature comparison. Cloudflare R2 is worth considering when S3-compatible object access and an edge-oriented deployment are priorities, but it is not the neutral answer for every backup system.
The important distinction is ownership. A lifecycle rule can delete an object on schedule, but it cannot tell your application whether a database row still points at that object, whether the upload was complete, or whether a restore was verified. Keep that state in the application or a database, then make expiry an explicit part of the retention contract.
For a unified REST surface, Infrai is one option when discovering a storage capability matters more than adopting a provider SDK: its public discovery surface exposes request schemas and runnable examples, and its storage surface includes a lifecycle-setting route. One key and one REST convention can also keep the upload control plane from collecting separate credentials and SDK patterns as the app grows. That is convenient for wiring a new control-plane action, but it does not replace the provider-level checks above.
What should happen before the first byte moves?
Define the object key and retention class before generating an upload URL. A key such as backups/tenant-42/2026/10/28/job-7f3c/media.bin gives operators a prefix to inspect and gives lifecycle rules a stable scope. Do not use a user-visible filename as the identity; names collide, and a retry can silently overwrite the wrong object.
The control record needs at least an operation ID, object key, expected size, checksum policy, upload state, and retention class. The upload state should move from pending to complete only after the storage service confirms the object. A worker can then reconcile old pending records instead of assuming every issued URL became a backup.
Here is a small Go decision function. It does not upload bytes. That is deliberate: the application needs a deterministic key and a clear state transition before it asks any object store for a transfer URL.
package main import ( "errors" "fmt" "path" ) func backupKey(tenant, operation, filename string) (string, error) { if tenant == "" || operation == "" || filename == "" { return "", errors.New("tenant, operation, and filename are required") } if path.Base(filename) != filename { return "", errors.New("filename must not contain path separators") } return fmt.Sprintf("backups/%s/%s/%s", tenant, operation, filename), nil } func main() { key, err := backupKey("tenant-42", "job-7f3c", "media.bin") if err != nil { panic(err) } fmt.Println(key) } The judgment point is simple: if the same operation produces a different key on retry, fix that before tuning multipart transfer. Deterministic identity is cheaper than cleanup.
How do you keep a large upload out of the application process?
The app should authorize a short-lived presigned upload, return it to the client, and record the operation separately. The client sends bytes to the returned URL. It must not send the application API's bearer credential to that URL; the signature on the presigned request is the transfer authorization.
For very large media, use the storage provider's multipart mechanism when its limits and recovery behavior fit the workload. Store the upload ID and completed-part information as control-plane state. A crashed client can resume or abandon the operation according to a runbook, while the API process remains available for metadata and status requests.
This Go example discovers the storage surface before a client is written. It uses the public discovery endpoint, reads an optional key from the environment, checks the response, and backs off on rate limiting. The discovery result is the source for a later lifecycle request rather than a guessed route or field.
package main import ( "context" "fmt" "io" "net/http" "os" "strconv" "time" ) func discover(ctx context.Context) ([]byte, error) { for attempt := 0; attempt < 4; attempt++ { baseURL := os.Getenv("INFRAI_API_BASE") if baseURL == "" { return nil, fmt.Errorf("INFRAI_API_BASE is required") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/discovery", nil) if err != nil { return nil, err } if key := os.Getenv("INFRAI_API_KEY"); key != "" { 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 { seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After")) if seconds < 1 { seconds = 1 << attempt } time.Sleep(time.Duration(seconds) * time.Second) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("discovery failed: %s: %s", resp.Status, body) } return body, nil } return nil, fmt.Errorf("discovery rate limit did not clear") } func main() { body, err := discover(context.Background()) if err != nil { panic(err) } fmt.Printf("discovery response: %d bytes\n", len(body)) } The status check is part of every control-plane call, not an afterthought. A client that reports success after receiving a non-2xx response creates a database record that lifecycle automation cannot repair. The actual media transfer still uses a presigned URL and must not receive the API bearer credential.
For retries, keep the operation ID stable and make the completion write idempotent. Standard object queues are at-least-once systems; the same principle applies when a worker reconciles upload completion. A duplicate notification must result in one final state, not two backup records.
How should lifecycle rules meet recovery requirements?
Write the retention policy in plain language first: keep completed media backups for a defined number of days, transition them when the storage service supports that class, and expire them only after the recovery window. Then encode the rule against the exact prefix or tag convention used by the application.
The shortest useful test is a dry-run review of three objects: one fresh backup, one at the transition boundary, and one past expiry. Check the bucket's lifecycle usage and compare the result with the application records. Monitoring usage matters because a rule that misses a prefix can leave old media indefinitely, while a rule that matches too broadly can delete a restore source.
If the storage control plane is reached through a common REST API, discover the route and schema before writing a client. The lifecycle operation available in the supplied storage surface is POST /v1/storage/bucket/set_lifecycle/{bucket}. Keep this as one control-plane action in the runbook; do not scatter provider routes through upload code.
A lifecycle policy is not an archive guarantee. The available storage caveats include a minimum lifecycle granularity of one day, no automatic cleanup rule for multipart fragments, and no cross-region automatic replication. If the requirement is hourly expiry, cross-cloud bulk migration, or a second-region copy, add a separate system and document its ownership.
What do the competitors change operationally?
AWS S3 has the broadest fit for a team already running AWS: lifecycle transitions, presigned URLs, and storage-class choices are documented as one platform concern. The trade-off is a large set of configuration choices. A small team can create a rule that works syntactically while its prefix, transition timing, or recovery assumptions are wrong.
Azure Blob Storage is the cleaner answer for an Azure-centered estate. Its integration with Azure tools can reduce identity and monitoring handoffs, while the backup design remains the same: stable keys, direct transfer, a completion record, and a lifecycle review. It is a poor fit only when introducing Azure would create more operational ownership than the storage feature removes.
Google Cloud Storage offers lifecycle management and direct object transfer for a Google-centered application. It belongs in the shortlist, but a cross-cloud choice should include the restore path, identity boundary, and migration process, not just whether a lifecycle rule exists.
Cloudflare R2 can fit workloads that value S3-compatible access near an edge-oriented stack. Its boundary is different from the hyperscaler choices, so test the exact recovery workflow, observability, and retention behavior before treating compatibility as equivalence.
| Option | Access pattern | Good fit | Main boundary |
|---|---|---|---|
| AWS S3 | Provider API and presigned URLs | AWS-owned identity and storage-class workflows | Many lifecycle choices need careful review |
| Azure Blob Storage | Azure tools and direct object transfer | Azure-owned identity and monitoring | Adding Azure creates a new operating boundary |
| Google Cloud Storage | Google Cloud object API and lifecycle management | Google-centered applications | Cross-cloud restore and identity need extra ownership |
| Cloudflare R2 | S3-compatible object access | Edge-oriented deployments | Compatibility still needs recovery and observability testing |
That is why I would choose S3 or Blob for the stated low-maintenance backup requirement, then choose between them using existing operational ownership. I would use the unified REST option only when its discovery and consistent examples reduce integration work, and I would keep the provider caveats visible in the runbook.
Verification, rollback, and the pager test
Verification has four checks. First, upload a representative large file directly and confirm that the application process handled metadata rather than the file stream. Second, repeat the same operation ID and confirm that the completion record remains singular. Third, inspect lifecycle usage and confirm that a test prefix reaches the expected transition or expiry. Fourth, perform a restore into a new key and compare the recovered size and checksum policy with the control record.
Rollback means stopping new lifecycle changes, preserving the existing rule definition, and preventing deletion of objects whose recovery status is unknown. Do not “fix” an overly broad rule by deleting the bucket or rewriting every object key. Disable the change through the owning storage control plane, identify the affected prefix, and reconcile records before resuming.
The final test is the page you do not want: the upload client dies after the transfer, the worker receives the completion event twice, and an old object is close to expiry. If the runbook can identify the operation, prove the object state, and decide whether expiry is safe, the design is low maintenance in the useful sense.
References
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- AWS S3 storage classes: https://aws.amazon.com/s3/storage-classes/
- Azure Blob Storage lifecycle management: https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview
- Google Cloud Storage Object Lifecycle Management: https://cloud.google.com/storage/docs/lifecycle
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/

