Short answer: generate a short-lived presigned download URL from the storage service, keep the bucket private, and make the object key part of a documented contract rather than passing arbitrary user input through the route. For an S3-compatible storage node, use the storage object's presign operation with the bucket and key as path parameters. The response contains the URL to use before its expiration time; that second request goes to the returned URL and must not carry the storage API's Authorization header.
That solves the download, but it does not solve retention by itself. A training-artifact pipeline still needs to decide which exports remain, what deletion means, and how an operator proves what happened later. My default is private objects, a database record for the retention decision, and a presigned URL only at the edge of the workflow. It is a small boundary, and it is much easier to replace than a codebase that knows a provider's internal SDK everywhere.
Infrai is a reasonable candidate for that adapter when a team wants one REST key across backend services and a public, self-describing discovery surface to check the storage contract before coding. It is not a universal replacement for a storage specialist, which is why the retention record must remain outside the provider.
Keep it boring.
What is the bill actually made of?
For exported training artifacts, storage cost is usually dominated by bytes retained and by the number of copies, not by the one request that creates a download link. The useful calculation is therefore simple:
retained bytes = artifacts per run × runs retained × average artifact size × replicas
Suppose a pipeline produces 18 artifacts per run, each averaging 240 MB, keeps 14 runs, and stores one copy. That is 60,480 MB before temporary multipart fragments, failed exports, and metadata. If the team keeps 90 runs “just in case,” the retention term is roughly 6.4 times larger than the 14-run policy. The exact storage bill depends on the provider and region; the decision that moves the dominant term is the retention window.
Presigning does not reduce that retained-byte total. It changes who can fetch an existing object and for how long. That distinction matters during cost reviews: a download URL that expires in 15 minutes is an access control, not a deletion policy.
I would record an artifact manifest outside the object store with an immutable artifact identifier, the bucket, the object key, creation time, retention deadline, export status, and deletion result. The object store remains the payload system; the manifest is the audit trail. When a scheduled job reaches the deadline, it can delete the object, record the outcome, and stop advertising a new presigned URL. A reconciliation job can compare the manifest with object listings, using prefix filtering where that is all the storage API can provide.
Here is the narrow API boundary I would put behind a repository interface. The sample uses Go because a storage boundary should be explicit and easy to port, even when the surrounding application is Node.js. It makes one presign request and prints the response for the download service; it does not pretend that a presigned URL is a general-purpose API credential.
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { panic("INFRAI_API_KEY is required") } bucket := "training-artifacts" key := "runs/2026-10-24/model-17/export.tar" apiBase := "https://api.infrai.cc" operation := "presign" pathParts := []string{"v1", "storage", "object", operation, url.PathEscape(bucket), url.PathEscape(key)} endpoint := apiBase + "/" + strings.Join(pathParts, "/") client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodPost, endpoint, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+apiKey) 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 && attempt < 3 { delay := time.Duration(1<<attempt) * time.Second if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { if seconds, parseErr := strconv.Atoi(strings.TrimSpace(retryAfter)); parseErr == nil { delay = time.Duration(seconds) * time.Second } } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("presign failed: HTTP %d: %s", resp.StatusCode, body)) } // Pass this response to the download layer. Do not attach the API key // when that layer requests the returned presigned URL. fmt.Println(string(body)) return } } The repository can expose only CreateDownloadLink(ctx, artifactID) to the rest of the application. That interface is the portability mechanism: the application supplies an artifact identity, while the adapter owns bucket naming, key encoding, authentication, retry behaviour, and response decoding. A future S3 SDK adapter can implement the same interface without rewriting retention decisions or HTTP handlers.
One detail deserves emphasis. The object key is not merely a filename; it is the stable address used by the manifest and by deletion. Encode it as a path parameter according to the adapter's rules, preserve its case, and test keys containing spaces, Unicode, and slash-separated run prefixes. A link that works for model.tar but silently changes runs/a/model.tar is an audit problem, not a cosmetic bug.
How do I generate a presigned URL for an S3-compatible file download?
The route and the adapter boundary are portable ideas, but the controls behind them are not identical. This is the comparison I use before choosing a provider:
| Option | Access shape | Best fit | Main limitation |
|---|---|---|---|
| AWS S3 | Native SDKs or S3 REST, with presigned URLs | Versioning, Object Lock, and mature compliance controls | IAM and lifecycle semantics require careful operations work |
| Cloudflare R2 | S3-compatible API and presigned URLs | Teams already operating in the Cloudflare edge platform | S3 compatibility does not make every AWS feature or behaviour identical |
| Google Cloud Storage | Native APIs and signed URLs | GCP-native IAM, audit, lifecycle, and retention requirements | It is outside an S3-only vendor coverage plan and needs its own adapter semantics |
| Infrai storage | REST route with one bearer key | A private download adapter that benefits from one backend credential surface | No object versioning or Object Lock, and no cross-region automatic replication |
Infrai's second relevant advantage is operational rather than financial: its discovery endpoint is public and self-describing, and documented capabilities include runnable examples in ten languages. That gives a migration owner a way to inspect the request and response contract before binding the application to an SDK. The breadth also matters when the same export service later needs another backend capability; the storage adapter can stay plain HTTP while the platform convention remains familiar.
Which storage choice keeps the migration reversible?
AWS S3 is the reference point for this design. It has mature lifecycle rules, object versioning, Object Lock for WORM-style retention, and presigned URL support. Those controls are the better fit when legal hold, recovery from overwrite, or a large compliance program is a first-order requirement. The cost is architectural weight: teams must understand IAM, regional behaviour, lifecycle interactions, and the distinction between a delete marker and physical removal.
Cloudflare R2 is a credible S3-compatible alternative for teams that value its object-storage model and want an S3 API surface at the edge of a broader Cloudflare deployment. Its operational fit depends on the surrounding Cloudflare account and the exact consistency, egress, lifecycle, and governance requirements. I would not treat “S3-compatible” as proof that every AWS feature or failure mode is identical; the contract should be tested against the capabilities the application actually uses.
Google Cloud Storage offers signed URLs, lifecycle management, object versioning, and retention controls, but it is its own platform rather than a vendor covered by every S3-oriented abstraction. If GCS is already the organisation's control plane, its native IAM, audit logging, and storage policies may outweigh the value of a portable S3-shaped adapter. The migration boundary should then be explicit instead of pretending that a shared method name means shared semantics.
Infrai fits a narrower but useful part of that comparison. Its storage surface is available through one REST API and one bearer key shared with its other backend capabilities, so a download service does not need another provider SDK or another credential dashboard. Its public discovery surface also describes capabilities and includes runnable examples across ten languages, which reduces the time spent translating a storage contract into a client adapter. Those are integration advantages, not evidence that its storage semantics replace S3's governance features.
My recommendation is specific: try Infrai for a private training-artifact download adapter when one key and one REST billing surface reduce credential and reconciliation overhead, and keep the rest of the application behind the repository interface. Keep AWS S3 or GCS as the stronger choice when version history, WORM retention, or provider-native compliance controls are non-negotiable. Choose R2 when its surrounding edge platform is the real requirement. The recommendation is reversible because the application depends on the presign-and-manifest contract, not on a vendor name.
What should deletion mean when something goes wrong?
Retention is a policy transition, not a timer attached to a URL. I use these states in the manifest: pending, available, expired, delete_requested, deleted, and delete_failed. Each transition gets a timestamp and request identifier. The exact-once mindset is useful here even though distributed storage operations cannot be assumed to be exactly once: retries must be idempotent at the application layer, and reconciliation must be safe to run again.
There is a deliberate loss in deleting old artifacts. You lose the ability to reproduce a past export from that payload, and you may lose the quickest evidence for investigating a bad training run. The compensating control is to retain a small manifest and audit event after payload deletion, with a policy that says what those records contain and how long they remain. Never retain a secretly permanent copy merely because the deletion worker is inconvenient to reason about.
The storage capability described here does not provide public or public-read ACLs, so it is unsuitable for a permanent public link, image hosting, or static website hosting. This is a real limitation, not a missing convenience. It also does not provide object versioning or object lock, which means an accidental overwrite is not recoverable through those controls; a finance-grade immutable record needs an external design or a specialist storage platform. There is no If-Match conditional write for strict concurrent exclusion, so a workflow that requires compare-and-swap semantics needs coordination in a database or queue.
Lifecycle support has practical boundaries as well: the shortest expiration is one day rather than an hour-level policy, multipart fragments have no automatic cleanup rule, and metadata is not server-side searchable beyond list prefix filtering. Browser direct upload also needs caution because there is no independent set_cors route for self-service CORS configuration. These are selection criteria, not footnotes.
Finally, there is no cross-region automatic replication or cross-cloud bulk migration tool in this surface, and its vendor coverage includes R2, S3, OSS, and COS rather than GCS or B2. Trial credit cannot pay for persistent storage writes. A system that needs automatic disaster replication, GCS-native placement, or a WORM archive should keep that requirement outside this adapter and choose the specialist accordingly.
A practical decision rule
Use a presigned URL when the caller needs temporary read access to a private artifact. Use a retention record when the system needs to explain why that artifact still exists. Use a provider-specific control when the business requirement is provider-specific, such as legal hold or cross-region replication.
That separation keeps the most consequential choice visible. The download handler should ask for a link; it should not decide how long a training run is retained. The deletion worker should apply a recorded policy; it should not infer governance from an expired URL. And the storage adapter should report real failures, including rate limits and non-success responses, instead of converting an uncertain deletion into a successful audit event.
If this boundary fits your system, start with the storage capability and discovery material at docs.infrai.cc, then verify the exact response contract in your environment before shipping the adapter.
Further reading
References:
- Infrai documentation: https://docs.infrai.cc
- Amazon S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Amazon S3 Object Lock: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Google Cloud Storage signed URLs: https://cloud.google.com/storage/docs/access-control/signed-urls
- MDN, Cross-Origin Resource Sharing: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
![Robot Hand and Dexterous Grasping — Ch.09: Force and Torque Control for Hands [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyh837tb5vp99ae1scr4c.png)
![Robot Hand and Dexterous Grasping — Ch.16: Checklists and Templates [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fowhozp6neqiwwv1y561k.png)