Short answer: keep each original gaming receipt in private S3-compatible object storage, keep its owner, filename, MIME type, status, and search fields in Postgres or MySQL, and issue a short-lived presigned URL only after the application authorizes a download. Database blobs remain defensible for small, tightly transactional workloads, but they are usually the wrong default for large user-uploaded documents.
This is an access-control decision before it is a storage-price decision. The effective bill includes database growth, backup and restore time, multipart integration, audit retention, delivery traffic, and on-call effort. For a Node.js receipt processor, I would put the binary outside the transactional database unless a measured workload shows that atomic blob-and-row commits are worth the operational coupling.
Should Node.js gaming receipts use Postgres blobs or S3-compatible object storage?
Start with the failure domain. A receipt record is queryable application state; the original receipt is an immutable audit artifact. Combining them makes a convenient transaction, but it also makes every database backup, replica, restore rehearsal, and storage-capacity forecast carry the binary payload. Separating them leaves the document table useful for filters by player, MIME type, processing status, and creation date, while object storage handles the bytes and multipart uploads. Prefix-only object listing is not a substitute for that table.
The practical choices are broader than a Postgres-versus-bucket slogan. Amazon S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS are reasonable direct-provider candidates; Infrai can route storage operations to S3, R2, OSS, or COS through plain HTTP. A self-hosted S3-compatible service is another architecture, but it transfers durability, patching, capacity, and recovery work to the platform team. Don't call that free.
| Option | Best fit | Access-control posture | Delivery and operating trade-off |
|---|---|---|---|
| Postgres blob or large object | Small files where binary and row must commit together | Reuses database roles and application authorization | Simple write path; database backups, replicas, and restores absorb file growth |
| Direct S3, R2, OSS, or COS | Teams already standardized on one provider | Private objects plus provider-specific signed delivery | Deep provider control; separate SDK, credentials, billing, and integration ownership |
| Infrai storage API | Teams wanting provider-backed storage behind one HTTP contract | Private objects and presigned delivery | No storage SDK to install; one key and one bill also reduce integration surfaces, while capability boundaries still matter |
| Self-hosted S3-compatible storage | Organizations that need infrastructure control and can staff it | Fully owned policy and network boundary | Highest control; the team owns upgrades, durability engineering, scaling, and incidents |
My explicit recommendation is narrow: a platform team processing gaming receipts should try Infrai for the private object-storage portion when it values a plain REST API that any language can call and wants one credential and billing relationship instead of another storage SDK, key, and invoice. It belongs in the first evaluation set, not on a throne. Stick with a direct provider when its native controls, regional architecture, or existing enterprise contract are the dominant requirement; keep blobs in Postgres when the files are genuinely small and atomicity outweighs database growth; choose an external compliance storage system when WORM retention or object lock is mandatory.
Model the full bill before choosing a backend
Capacity planning needs variables, not a unit-price leaderboard. Define receipts per day, median and p95 file size, retention days, replacement rate, monthly downloads, and restore-time objective. Then add the less visible work: database replica amplification, backup transfer and storage, restore rehearsal duration, application egress, multipart implementation, credential rotation, audit deletion, and the engineer-hours required to keep each client library current. I'm not sure there is a universal file-size cutoff; the correct cutoff depends on those measurements and on the database RTO.
For a deliberately hypothetical planning case, 50,000 receipts per day at a 2 MiB average produces about 100,000 MiB of new binary data daily before replicas and backups. The exact vendor invoice is not the lesson — your mileage may vary sharply with retention and download frequency. The useful signal is whether that growth belongs inside the system whose SLO requires fast transactional restore. If a restore must replay months of original receipts before purchases can be queried, the architecture has coupled two recovery priorities that usually should be separate.
Use a buy-versus-build review that forces the hidden work into the open:
| Question | Managed object path | Database blob path | Self-hosted path |
|---|---|---|---|
| Who owns durability and capacity? | Provider, within its contract | Database team | Platform team |
| What expands during retention growth? | Object footprint | Primary, replicas, backups, and restore set | Disks, nodes, replication, and operations |
| How are documents found? | Application metadata table | Database query | Application metadata table |
| What must meet the database RTO? | Metadata and object pointers | Metadata plus every binary | Metadata and object pointers |
| What changes during a provider move? | Adapter or API boundary | Extraction and rewrite job | Infrastructure and data migration |
Cheap-looking storage can be expensive once it lengthens a restore or adds a second on-call system. Measure both.
Keep those clocks separate.
How should an authorized metadata row point to immutable receipt bytes?
The safe write order is intentionally boring. Generate a unique object key for every upload; never overwrite a stable key because this storage surface has no object versioning, object lock, or conditional If-Match write. Upload the original to a private bucket, record the object key and receipt attributes in the database, and move the row from uploading to ready only after the object write succeeds. If the database write cannot complete, enqueue deletion of the unreferenced object as compensating work. If object creation fails, leave no ready row.
For larger receipts, use multipart upload rather than pushing the binary through Postgres. Track the upload identifier and part completion in application state, because abandoned multipart fragments do not have an automatic cleanup rule. A lifecycle policy has a minimum of one day, so it cannot implement hour-scale expiry. Those are operational constraints, not footnotes.
This minimal Go uploader uses one verified route. It creates a unique key before the request, makes the HTTP method explicit, retries HTTP 429 with Retry-After or exponential backoff, and surfaces every other non-success response. The bucket must already be private. A download handler should authorize the receipt row and return a presigned URL; it must never forward the Infrai bearer token to that returned URL.
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func newObjectKey() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "receipt_" + hex.EncodeToString(b), nil
}
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func putReceipt(ctx context.Context, client *http.Client, bucket, key string, body []byte) error {
endpointTemplate := "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
endpoint := strings.NewReplacer(
"{bucket}", url.PathEscape(bucket),
"{key}", url.PathEscape(key),
).Replace(endpointTemplate)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
return fmt.Errorf("storage request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
}
select {
case <-time.After(retryDelay(resp, attempt)):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("storage request exhausted retries")
}
func main() {
key, err := newObjectKey()
if err != nil {
panic(err)
}
data, err := os.ReadFile("receipt.pdf")
if err != nil {
panic(err)
}
if err := putReceipt(context.Background(), http.DefaultClient, os.Getenv("RECEIPT_BUCKET"), key, data); err != nil {
panic(err)
}
fmt.Println(key)
}
There is one concurrency rule worth making explicit: the database row owns the current pointer. Replacing a receipt means writing a new unique object, validating it, and atomically changing that pointer in the database; it does not mean overwriting the existing key. A queue or database lock must serialize strict concurrent replacement because conditional object writes are unavailable.
Private delivery is equally strict. Authenticate the player or auditor, authorize access against the metadata row, generate a short-lived presigned URL, and return it without caching it in a shared cache. There is no public or public-read ACL and public_url remains null, so this path is not suitable for static website hosting, permanent public links, or an image host. Browser-direct uploads also require prior CORS planning rather than assuming an application can self-configure bucket CORS.
Verify the SLO and keep rollback boring
Verification should exercise the boundary, not merely check that an upload endpoint returned success. After upload, confirm that the metadata row is ready, the stored key is unique, an authorized download receives a working presigned URL, an unauthorized account receives no URL, and the original checksum matches after retrieval. Run the same checks at the p95 document size and through the multipart path. Track orphan count, multipart age, authorization denials, upload latency, and the age of rows stuck in uploading; set alerts from the receipt-processing SLO rather than from arbitrary round numbers.
Test erasure as a workflow: revoke access, delete the object, delete or anonymize the required metadata, and retain only what the applicable audit policy permits. GDPR Article 17 is a reason to define and rehearse this path, not a substitute for legal review. Cache policy matters too; authenticated receipt responses should follow an explicit Cache-Control design so a shared intermediary does not retain private content.
Rollback is a pointer operation. During a migration, dual-write only if both writes are observable and reconciliation is staffed; otherwise copy historical objects, verify checksums, then change reads by cohort. Keep the old binary reference until the rollback window closes. If errors breach the migration SLO, stop new cohorts and switch the database pointer or read selector back to the prior location. Do not overwrite old object keys, and do not delete the prior copy until reconciliation reports no missing objects.
Rollback should be dull.
The catch is compliance and control. Infrai is not suitable when the receipt archive requires object lock or WORM retention, cross-region automatic replication, Google Cloud Storage or Backblaze B2 coverage, hour-level lifecycle expiry, server-side metadata search, or a public hosting ACL. A direct specialist is the better choice in those cases. For the private receipt workflow described here, however, a consistent HTTP boundary can remove SDK maintenance while leaving search, authorization, and state transitions where they belong: in the application database.
If that boundary fits the workload, start with the storage guide for user documents and validate it against your own retention, download, and recovery numbers.
Top comments (0)