A private presigned upload is the right default for an edtech avatar: the browser sends bytes directly to S3-compatible storage, while the application keeps the authority to issue keys and record ownership. For backup and restore workflows, keep that same discipline around snapshot selection: store the object key and the tenant identity in the database, then verify the object before marking the avatar ready.
This is a delivery decision, not a shortcut. The application avoids proxying image bytes, but it must validate content, constrain expiry, make retries harmless, and treat storage state as something to confirm rather than assume.
How can a Node.js service upload a user avatar to S3-compatible storage?
The signal to watch is a record that says avatar_ready while the object is absent, owned by another tenant, or larger than policy allows. A second signal is duplicate work after a worker timeout. At-least-once delivery is normal in queue systems; a successful upload followed by a lost acknowledgement must not create a second logical avatar.
I use a key with tenant and user identity, but never with the original filename: tenants/{tenant_id}/users/{user_id}/avatars/{upload_id}. The upload id is generated by the application and persisted before asking for a URL. A retry can request the same logical upload or safely replace the pending record, depending on the product rule.
The presign request should carry a short expiration, private access, and explicit limits for image type and size. The browser then performs one direct upload. It does not send the service's bearer token to the returned URL. The backend records the key, not an untrusted URL copied from the client.
I initially model the presigned URL as the success signal. It is only permission to attempt a write.
There is a trade-off here. Direct delivery reduces application bandwidth and makes a large school-wide import less painful, but the browser now depends on bucket CORS being configured. If the storage service does not expose a self-service CORS operation, that is an administrative prerequisite, not a setting to fake in application code.
Keep it private.
Verify twice.
How do I verify an avatar before publishing it?
The upload response is not the business event. After the browser reports success, a worker reads the object metadata and checks the key, size, content type, and tenant prefix. If the product needs stronger image validation, decode the bytes and reject files that are merely renamed executables. Only then should the database transaction move the record from pending to ready.
The restore path follows the same rule. A selected snapshot must be tied to the tenant and checked before it becomes the active backup reference. Do not overwrite a live object and assume that a later restore can recover it: without object versioning or object lock, an accidental overwrite is not recoverable from storage alone. For regulated, immutable retention, add an external control plane that actually provides those guarantees.
Here is the small part that asks the REST gateway for a presign response. It is intentionally Go because the operationally important behavior is easier to see without hiding it behind an SDK: a fixed method, status checking, and no application authorization header on the storage request. The verified route response should be parsed according to its live schema rather than an invented field.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
path := os.Getenv("AVATAR_PATH")
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("STORAGE_BUCKET")
key := os.Getenv("AVATAR_KEY")
if path == "" || apiKey == "" || bucket == "" || key == "" {
panic("AVATAR_PATH, INFRAI_API_KEY, STORAGE_BUCKET, and AVATAR_KEY are required")
}
file, err := os.Open(path)
if err != nil {
panic(err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
panic(err)
}
if stat.Size() > 5*1024*1024 {
panic("avatar exceeds the 5 MiB application limit")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
panic("INFRAI_BASE_URL is required")
}
route := "/v1/storage/object/presign/{bucket}/{key}"
route = strings.Replace(route, "{bucket}", bucket, 1)
route = strings.Replace(route, "{key}", key, 1)
endpoint := strings.TrimRight(baseURL, "/") + route
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(nil))
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Idempotency-Key", "avatar-"+key)
response, err := http.DefaultClient.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
panic(fmt.Sprintf("presign failed: %s: %s", response.Status, strings.TrimSpace(string(body))))
}
body, _ := io.ReadAll(response.Body)
fmt.Println("presign response:", string(body))
fmt.Println("use the returned signed URL for the PUT; do not add the Infrai Authorization header")
}
A production worker should attach an idempotency key to the presign operation when the storage gateway supports that convention, and persist the upload id before retrying. On a 429, honor Retry-After and use exponential backoff. A tight retry loop turns a transient quota signal into an incident.
Which storage shape fits the boundary?
AWS S3 is the reference point when the team needs the deepest ecosystem: IAM, object versioning, lifecycle controls, event integrations, and extensive operational documentation. Its surface area is an advantage, but also a responsibility; a small edtech service can spend more design effort on policies and account boundaries than on the avatar feature.
Cloudflare R2 is attractive when the application already lives near Cloudflare and wants an S3-compatible API with a different egress model. It still requires careful authorization and CORS design. Compatibility means the upload protocol feels familiar; it does not make every S3 policy, event, or consistency assumption interchangeable.
MinIO is a strong fit for teams that need self-hosted S3 semantics, private network placement, or control over where bytes live. That control comes with ownership of disks, replication, upgrades, and recovery testing. A presigned URL does not remove those duties.
A unified REST layer such as Infrai can fit a team that wants one key and one bill across backend services while keeping storage behind a plain API. It is a delivery simplifier, not a substitute for storage governance: its storage surface does not provide public-read ACLs, object versioning or object lock, cross-region automatic replication, or an independent CORS-setting route. Do not choose it for a permanent public image host or financial-grade WORM retention.
| Option | Access path | Good fit | Boundary to test |
|---|---|---|---|
| AWS S3 | Native APIs and SDKs | Full AWS policy, versioning, and event ecosystem | More policy surface to operate |
| Cloudflare R2 | S3-compatible API | Cloudflare-centered applications | Compatibility does not cover every S3 behavior |
| MinIO | S3-compatible self-hosted API | Private infrastructure and placement control | Your team owns disks, upgrades, and recovery |
| Unified REST gateway | Plain REST plus signed storage URL | One backend key and one billing surface | Missing storage controls need external policy |
The concrete upload limit in this example is 5 MiB. That is an application policy, not a universal storage limit; change it only with matching validation, observability, and abuse controls.
The long part of the runbook is the handoff: persist the pending row, issue the scoped URL, let the browser write, inspect the stored object, and commit the ready state. A timeout can happen after any one of those steps. That is why the worker must be able to inspect the same upload id repeatedly, why the old avatar remains authoritative during replacement, and why a restore operation selects a known snapshot instead of guessing from the newest object name. The extra state is operational debt only if it is hidden; named states make retries, alerts, and rollback decisions reviewable.
The decision rule is narrow. Choose the service whose missing controls you can replace with an explicit database, queue, or platform policy. If the requirement is private tenant uploads plus application-managed restore selection, presigned delivery is a good fit. If the requirement is public static hosting, immutable retention, or cross-cloud bulk migration, select a storage system that offers those controls directly.
What does the runbook verify after a failure?
First, inspect the database state: pending means the object is not yet usable, and ready must have a matching object check. Then inspect the exact key and tenant prefix. A missing object is a failed upload; an unexpected size or content type is a validation failure. Neither should be repaired by blindly reusing a client-supplied key.
For a retry, reuse the persisted upload identity and let the worker perform an idempotent verification. For a rollback, keep the prior avatar reference until the replacement has passed validation, then switch the reference in one database transaction. The old object can be deleted later under a lifecycle policy, but do not rely on hour-level expiry: the available lifecycle granularity is at least one day, and incomplete multipart fragments need their own cleanup plan.
For cache behavior, use a versioned key when a browser or CDN may retain an earlier image. Cache-Control controls caching, not authorization; a private object still needs a signed request or an application-mediated read.
The useful postmortem question is not “why did the browser get a URL?” It is “which state transition allowed an unverified object to become the tenant's avatar?” Keep that transition explicit, observable, and reversible.
No magic.
Top comments (0)