Short answer: for a gaming system that retains signed documents until an explicit deletion deadline, put the document catalog and tenant policy in a relational database, and put the file bytes behind an object-storage boundary. Keep database blobs for a small, tightly bounded corpus where one transaction is more important than independent file delivery. The deciding constraint is tenant isolation, not a simplistic claim that one backend is always cheapest.
That distinction matters because a signed document is not just a file. It is a tenant-owned record with an issuer, a signature status, a retention deadline, and an audit trail. The bytes need durable handling, but the application needs to answer questions about ownership and deletion without scanning payloads. I don't want a bucket listing to become an accidental authorization system.
The retention contract comes before the storage engine
Start with invariants. Every document must have exactly one tenant identifier, a server-generated opaque document ID, a storage key that cannot be selected by the uploading user, and a deletion deadline that the cleanup process can evaluate. The database row is the authority for those facts. The object key is a pointer, not a permission.
The upload should move through explicit states: pending, ready, and deleted. A pending row is not downloadable. A ready row is downloadable only after the service checks the authenticated tenant against the row's tenant ID. At the deadline, the service first prevents new downloads, then removes the object, and finally records deletion. If those operations are separated by a crash, a reconciliation job can find the unfinished state. Silent ambiguity is the dangerous state.
For signed documents, preserve the signature metadata separately from the payload: signer or signing-system identifier, signed-at timestamp, digest if the signing protocol supplies one, and the retention rule that produced the deadline. Do not treat an object filename as evidence. A filename is user input with a costume on.
The deletion rule also needs a legal and product decision behind it. GDPR Article 17 describes a right to erasure with exceptions, so an automatic deadline cannot be the only policy in every jurisdiction or workflow. Record why a document is retained and why deletion is allowed; route exceptions to an explicit policy decision rather than quietly extending the object forever.
How should a Node.js backend store uploaded user documents?
The recommended boundary is plain: Postgres or another relational database stores the document row, tenant ownership, state, policy dates, and audit events; object storage stores the immutable-by-convention bytes under a fresh key. The application performs authorization and lifecycle transitions. Neither backend is allowed to infer the other backend's responsibilities.
| Option | Good fit | Cost or risk to accept |
|---|---|---|
| Database blob | Small corpus, low upload volume, and a hard requirement that metadata and bytes share one transaction | Backups, restores, replication, and database capacity all carry the file payload |
| S3-compatible object storage | Uneven file sizes, independent downloads, multipart upload, and a clear object boundary | The application must own the metadata catalog, tenant checks, and cleanup state |
| Hybrid catalog plus objects | Signed documents need relational search, retention queries, and separately delivered bytes | A crash can leave a row and object temporarily out of sync, so reconciliation is mandatory |
The hybrid option is the practical default for this scenario. It is not a distributed transaction. It is a deliberately observable two-step workflow with enough state to repair an interrupted step.
One short rule.
Store meaning in the database. Store bytes in the object tier.
The catch is that object storage does not solve tenant isolation for you. Prefixing keys with a tenant ID helps operators inspect data, but a prefix is not an authorization check. The service must load the row by document ID and the authenticated tenant together, and it must refuse a mismatch before it asks the object tier for anything. A direct client upload can be useful, but it requires an equally strict capability design: the upload token must be scoped to one generated key, one tenant, and one expiry window.
The object boundary leaves work for the application
This Python sketch shows the state transition without binding the decision to a particular SDK. A Node.js service can implement the same sequence with its normal database driver and object-storage client; the important part is the ownership check and the order of state changes.
from dataclasses import dataclass from datetime import datetime, timezone from uuid import uuid4 @dataclass(frozen=True) class Document: document_id: str tenant_id: str storage_key: str delete_after: datetime state: str def begin_upload(tenant_id: str, delete_after: datetime) -> Document: document_id = str(uuid4()) storage_key = f"signed-documents/{tenant_id}/{document_id}" return Document(document_id, tenant_id, storage_key, delete_after, "pending") def authorize_download(document: Document, authenticated_tenant: str) -> str: if document.tenant_id != authenticated_tenant: raise PermissionError("document is outside the authenticated tenant") if document.state != "ready": raise FileNotFoundError("document is not available") if datetime.now(timezone.utc) >= document.delete_after: raise FileNotFoundError("document has passed its deletion deadline") return document.storage_key def mark_ready(document: Document, uploaded_key: str) -> Document: if uploaded_key != document.storage_key: raise ValueError("uploaded key does not match the generated key") return Document( document.document_id, document.tenant_id, document.storage_key, document.delete_after, "ready", ) In real code, begin_upload and its insert belong in a database transaction. The object write happens against the generated key. Only a successful object write permits the row to become ready. If the write is interrupted, the row remains pending and a job can inspect the age of the record; it must not guess that a missing confirmation means the upload succeeded.
The reverse problem is just as common: the object exists, but the database transaction that should point to it did not commit. Use a short-lived pending marker and an operator-visible reconciliation query. Delete an unreferenced object only after the grace period is compatible with retries. A cleanup job that runs twice must be harmless; deletion should be idempotent, and an already absent object should not make the database claim a successful download.
I would also treat an HTTP 404 at the application boundary as “not available,” whether the underlying cause is an expired record or an object already removed. Do not return different messages that let a caller probe another tenant's document IDs. The audit event can retain the internal reason; the download response need not.
A small implementation with explicit state
The database-blob design fails operationally when large payloads turn routine database work into payload work. A schema migration, replica catch-up, backup, or restore now carries the document corpus. That may still be acceptable for a small internal system, but it should be written down as a capacity limit, not discovered during an incident.
The object design has a different failure boundary. Metadata queries cannot be replaced by object listing, and a storage key alone cannot prove tenant ownership. A missing cleanup worker can leave expired bytes behind; an over-eager cleanup worker can remove a document before an exception review finishes. Test both paths with fake clocks and a disposable object tier.
Retention has another trap: cache behavior. A previously issued download URL or an intermediary cache can outlive the database state unless response directives are chosen to match the sensitivity and deadline. MDN's Cache-Control reference documents directives such as private and no-store; the application still owns the decision about what a signed document may be cached and for how long.
The recommended design is not suitable when the system cannot operate a reconciliation process, when the file corpus is genuinely tiny, or when a strict single-transaction commit is the dominant invariant. In those cases, keep the bytes with the relational record until the operational boundary changes. Conversely, stick with object storage when downloads, large files, or tenant-scoped retention are the recurring workload. Your mileage may vary because the restore procedure and legal retention exceptions can outweigh raw storage arithmetic.
Consider a tenant with two signed documents whose deadlines are only a few minutes apart. The first cleanup pass marks both rows as deletion-started, removes one object, and then loses its database connection before removing the second. A retry must inspect durable state and continue; it must not recreate a document, extend the deadline, or treat the first object as a failure simply because the second operation was interrupted. The same reasoning applies to a client retry after a timeout: if the original object write completed, a new random key can create an orphan unless the request carries an application-level idempotency identity. The database can record that identity and the generated key, while a reconciler compares catalog rows with object keys after the retry window. This is where the hybrid design earns its complexity: it gives operators a finite list of ambiguous records to inspect. A blob-only design hides the same question inside database recovery, and an object-only design has no trustworthy tenant catalog to answer it. It's a small workflow, but it needs a state model.
I'm not sure any storage comparison can choose the retention exception for you. That belongs to the product and legal owners.
What must be tested before production?
Test the policy, not merely the upload form. A useful test matrix includes a tenant mismatch, a pending row, a deadline exactly at the current time, a retry after an object write succeeds but the database commit fails, and a deletion retry after the object is already gone. Add a restore rehearsal that proves the catalog and objects can be reconciled from backups; a green upload test says very little about recovery.
Observability should expose counts and ages rather than document contents: pending rows older than the retry window, ready rows past their deadline, objects with no catalog row, failed deletion attempts, and authorization denials by tenant. Keep document IDs opaque in logs and avoid logging signed bytes, tokens, or complete filenames when they are not needed.
The decision is therefore a boundary decision. Use database blobs when bounded simplicity is the requirement. Use a relational catalog plus tenant-scoped object keys when signed documents need independent delivery and deadline-driven deletion. Either way, make ownership, state, and recovery explicit before comparing a provider's price sheet.
