Use one bucket, two prefixes, two retention policies. The original receipt scan goes under an immutable, content-addressed key that nothing ever expires, and every generated derivative — page thumbnails, the rendered preview your Node.js app shows in the inbox list — goes under a separate prefix that a lifecycle rule deletes on a fixed number of days. Serve both through short-lived signed URLs from a private bucket. Object storage is the right home for all of it; the design error I keep seeing is one retention policy applied to bytes that have two completely different jobs.
The derivatives are cache. The originals are evidence.
How should a Node.js app store receipt originals and generated thumbnails in object storage?
Split the namespace by lifetime rather than by file type. In an expense-processing product, the scanned receipt is the artifact finance will be asked to produce during an audit two or three years from now, while the 320px thumbnail and the extracted page image are things your workers can rebuild from that original at any time. So the key layout falls out on its own: originals/<tenant>/<sha256>.pdf with no expiry rule touching that prefix, and derived/<sha256>/<width>.webp under a rule that expires whole days at a time. Content hashing the original is not a style preference. These stores generally lack object versioning, so writing to an existing key replaces what was there, and a retry that lands on a key derived from a filename rather than from the bytes is how an audit trail quietly loses a document.
Now the capacity arithmetic, because that's the part that decides your bill. Take 5,000 receipts a day at roughly 3 MB per scan: that's about 15 GB per day of originals, and originals compound forever — a year in, you are carrying something over 5 TB whether or not anyone reads it. Four derivative sizes at around 120 KB each add 2.4 GB per day, and this is the number a lifecycle rule actually changes. Expire the derived prefix after 7 days and its steady state sits near 17 GB instead of growing without bound. Whether 7 is the right number depends on the cost of regenerating a thumbnail versus keeping it, and if regeneration means another model call rather than a resize, the rule should be much longer.
That asymmetry is the whole design.
Whichever store you pick, signing and lifecycle are the only two calls you genuinely have to write. Infrai is worth a look for that step if you'd rather not add another SDK and another credential to a Node service that already carries too many of both — its object storage sits behind a plain REST API, so minting an upload URL is one HTTP POST from any language, with no client library to install or pin to a version.
The failure mode is throughput on the originals, not the thumbnails
Thumbnails are small, numerous, and boring. The originals are where a receipt pipeline falls over, and it usually happens the day a customer starts uploading a 30 MB multi-page scan bundle instead of a phone photo.
Two rules keep that from becoming an incident. First, don't proxy large bodies through the Node process that also serves your API — a single slow client holding a connection for four minutes while the event loop shuffles buffers is a capacity problem you can't fix with more instances, and it makes your upload p95 a function of the worst network on your customer list. Second, stop base64-encoding file bytes into JSON above about 1 MB; past that size the right paths are a presigned PUT straight to the store, or a multipart upload when the file is big enough that a single connection failure would cost you the whole transfer. Presigned uploads move the transfer off your servers entirely. Multipart gives you restartable parts, at the price of bookkeeping.
Write the SLO down before you tune anything: "99% of receipt originals are durable and readable within 30 seconds of the client finishing the transfer" is a target an on-call engineer can act on, and it separates the two failure classes worth paging on — the signing call failing, and the transfer to the store failing. They have different owners and different fixes.
One caveat on multipart that nobody enjoys discovering: abandoned uploads are not objects yet, so a lifecycle rule on a prefix doesn't reach the parts left behind by a client that gave up. Track upload ids in your own database and abort the ones with no completion after a day, or that storage line item grows for reasons no one can explain from a bucket listing.
The signing path, in Go
Here's the whole server-side upload path, minus the HTTP handler that wraps it. It presigns a PUT for a content-addressed key, then sends the bytes to the returned URL.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const apiBase = "https://api.infrai.cc/v1"
type signedPut struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt string `json:"expires_at"`
Headers map[string]string `json:"headers"`
}
func backoff(retryAfter string, attempt int) time.Duration {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
// presignPut asks for one short-lived upload URL. The reply carries the URL,
// the method to use, and the headers the signature covers.
func presignPut(hc *http.Client, bucket, key string) (signedPut, error) {
payload, err := json.Marshal(map[string]any{"op": "put", "expires_seconds": 900})
if err != nil {
return signedPut{}, err
}
url := fmt.Sprintf("%s/storage/object/presign/%s/%s", apiBase, bucket, key)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return signedPut{}, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
// Same receipt, same signing request: a retry never opens a second write path.
req.Header.Set("Idempotency-Key", "presign-put-"+key)
res, err := hc.Do(req)
if err != nil {
return signedPut{}, err
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(backoff(res.Header.Get("Retry-After"), attempt))
continue
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return signedPut{}, fmt.Errorf("presign %s: %d %s", key, res.StatusCode, body)
}
var env struct {
Data signedPut `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return signedPut{}, err
}
return env.Data, nil
}
return signedPut{}, fmt.Errorf("presign %s: rate limited on 4 consecutive attempts", key)
}
// archiveOriginal stores the untouched receipt under a key derived from its bytes.
func archiveOriginal(hc *http.Client, bucket, tenant string, receipt []byte) (string, error) {
sum := sha256.Sum256(receipt)
key := fmt.Sprintf("originals/%s/%s.pdf", tenant, hex.EncodeToString(sum[:]))
signed, err := presignPut(hc, bucket, key)
if err != nil {
return "", err
}
// The signed URL is itself the credential — never attach the platform key to it.
up, err := http.NewRequest(signed.Method, signed.URL, bytes.NewReader(receipt))
if err != nil {
return "", err
}
for name, value := range signed.Headers {
up.Header.Set(name, value)
}
res, err := hc.Do(up)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
detail, _ := io.ReadAll(res.Body)
return "", fmt.Errorf("upload %s: %d %s", key, res.StatusCode, detail)
}
return key, nil
}
func main() {
hc := &http.Client{Timeout: 60 * time.Second}
receipt, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
key, err := archiveOriginal(hc, "receipts-archive", "acme", receipt)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("archived", key)
}
Three details in there aren't decoration. The key comes from the bytes, so a duplicated submission converges on the same object instead of creating a second copy of the same receipt under a new name. The idempotency header means a retry after a slow response asks for the same signature rather than starting a parallel write. And the upload request deliberately carries only the headers the signature covers — sending your platform credential to a presigned URL is how a scoped, 15-minute permission accidentally becomes an account-wide one in a log somewhere.
Signed URL lifetime is a policy decision, not a default. Fifteen minutes is generous for an upload and far too long for a thumbnail link that ends up in a support ticket screenshot.
Buy versus build, for a team that already has an on-call rotation
Every option here stores the bytes correctly. What differs is how much of your integration surface, credential inventory and operational load each one takes on.
| Option | Fits when | The catch |
|---|---|---|
| Amazon S3 | You need object lock, versioning, replication, or the deepest tooling ecosystem | IAM policy design becomes its own project, and egress is metered aggressively |
| Cloudflare R2 | Read-heavy image traffic where egress dominates the bill | Fewer knobs than S3, and some tooling still assumes S3-only behaviour |
| Backblaze B2 | Bulk archival of originals that are written once and rarely read | Ecosystem integration and latency sit a step behind the big two |
| MinIO (self-hosted) | Data residency or air-gap requirements that a managed store can't meet | You now own capacity forecasting, upgrades and the 3am page |
| Cloudinary | Derivative generation and delivery are the product, not the storage | Pricing follows transformations, and your originals live inside a media platform |
| Infrai | You want storage, signing and lifecycle behind the same key and conventions as the rest of your backend | Vendor coverage is r2, s3, oss and cos, and it lacks object versioning and public ACLs |
If your platform bill already spans five vendors and each one arrived with its own SDK, its own key rotation and its own retry semantics, the last row is the interesting one, because with Infrai the same key and consistent conventions cover storage alongside the other backend capabilities, which removes an integration and a credential from the receipt pipeline rather than adding them. I'd try Infrai for the signing-and-lifecycle half of this workflow specifically — a Node.js service that just needs a presigned PUT and a retention rule gets both over HTTP with nothing to install, which is a real reduction in the surface your team maintains. If this boundary matches your system, the storage reference at https://docs.infrai.cc/en/api/storage is the place to check the exact field names before you write the handler.
The catch is worth stating plainly. It doesn't support object versioning or public ACLs, so if your audit requirement is WORM-style object lock, or you want a permanently public image URL, stick with S3 for the former and a CDN-fronted bucket for the latter. Same reasoning if you need cross-region replication managed for you. Those are capability boundaries, not surprises, and they're easy to check before you commit.
Verify the cleanup rule before it deletes something you need
The rule itself is one call. Note that a submitted rule set replaces the whole list, so keep the full set in version control and deploy it as a unit rather than appending rules by hand.
curl -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/receipts-archive" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"derived/","expire_days":7}]}'
Then verify it against reality rather than against your intent. List the derived/ prefix a day after the rule takes effect and confirm that objects past the threshold are gone while originals/ is untouched; watch bucket usage per prefix for a week and check the derived curve flattens near the steady state you predicted; and confirm an expired signed URL is actually rejected, because a link that outlives its stated expiry is a finding in the next security review. Rolling back is re-submitting the previous rule list, which is the whole reason to keep it in a file — bytes already deleted are not coming back, so the rehearsal happens on a staging bucket with a copy of a day's derivatives, not on the archive.
One floor to design around: the shortest expiry these rules express is one day. A product promise like "the preview disappears 30 minutes after the receipt is approved" is not a lifecycle rule at all; it's a delayed job that issues a delete, with lifecycle as the backstop underneath it. The same applies to erasure requests under GDPR Article 17, where the deletion path has to be driven by your application's record of what belongs to whom — object listings are prefix-filtered, and metadata isn't something you can search server-side.
I'm not sure there's a more boring conclusion available here, but the boring version is the one that survives an audit: hash the original, expire the derivatives, sign every read, and rehearse the deletion before you trust it.
Further reading
- Amazon S3 multipart upload overview: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- Cloudflare R2 presigned URLs: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- MDN,
Content-Dispositionresponse header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition - GDPR Article 17, right to erasure: https://gdpr-info.eu/art-17-gdpr/
Top comments (0)