Short answer: for a SaaS that serves generated reports to authenticated customers, keep the bytes in private object storage, keep ownership and lifecycle state in Postgres, and use short-lived signed upload and download URLs as capabilities issued by an authenticated Node.js service.
That decision separates delivery simplicity from access control. The browser sends the large payload directly to object storage, while the application remains the place that knows which customer may create, replace, or read a report. A signed URL is a temporary permission to perform one storage operation; it is not a document record and it is not a substitute for an authorization check.
I use that distinction as a capacity-planning reflex. Application servers should spend their budget authenticating requests and recording state, not proxying every report byte. The SLO still belongs to the whole workflow, though: a fast upload that leaves an unreadable or ownerless object is a failed customer operation.
How should a SaaS handle private documents, browser uploads, signed URLs, Node.js, and Postgres?
Start with an application request, not with a bucket listing. The Node.js service authenticates the customer, checks the requested report type and size against policy, generates an opaque upload ID, and writes a pending row in Postgres. The row should contain the tenant or customer ID, object key, display name, expected content type, state, and timestamps. Only after that durable intent exists should the service mint a signed upload URL.
The object key should be generated by the service. User-provided filenames are display data, not identity; using them as the authority boundary creates collisions, awkward escaping, and accidental disclosure. An opaque key also makes retries easier to reason about because a retry can refer to the same pending upload without guessing which object a filename meant.
The read path is deliberately asymmetric. A customer asks for a report by the application's report ID. The service loads the Postgres row, evaluates tenant membership and report state, and only then returns a short-lived signed download URL. The browser never turns an object-storage prefix into a search API, and a URL copied from one customer session does not become a durable product permission.
The incident test: what happens when a signed upload stops halfway?
The production scenario I put in a design review is bounded: a report is generated, the browser receives its signed upload URL, and the laptop closes before the transfer completes. The invariant is simple. A pending report must not be downloadable, and a later retry must resolve to a known row rather than create an unowned object.
That is the control plane.
That sounds obvious until the state transitions are written down. pending means the application has authorized an intended object but has not accepted its bytes. ready means the completion path has verified the object exists and recorded whatever metadata the product needs. deleted means the application will refuse new signed reads even if cleanup of the physical object is still asynchronous. There should also be an explicit failure or expiry policy, because an abandoned pending row is operational work, not a mysterious missing file. I would give each state an owner, a timestamp, and a metric before shipping the UI: otherwise an operator cannot distinguish a customer who cancelled, a browser that lost connectivity, a signer that timed out, and a cleanup process that simply fell behind, and those cases need different retry, notification, and retention decisions.
The failure I worry about is a completion endpoint that trusts the browser's claim that an upload succeeded. It turns a network event into a business fact. The safer endpoint checks the expected upload ID and key, confirms the storage object, applies size and type policy, and performs a conditional database transition. If two completion requests arrive, only one may move the row from pending to ready.
Here is the control-plane portion in Go. The storage client is intentionally an interface: its implementation can create a signed upload URL for the chosen object-storage system, while the database transaction remains the application contract.
package reports
import (
"context"
"database/sql"
"fmt"
)
type Signer interface {
SignUpload(ctx context.Context, key, contentType string) (string, error)
}
func BeginUpload(ctx context.Context, tx *sql.Tx, signer Signer, tenantID, uploadID, name, contentType string) (string, error) {
key := fmt.Sprintf("reports/%s/%s", tenantID, uploadID)
const insert = `
INSERT INTO report_uploads
(upload_id, tenant_id, object_key, display_name, content_type, state)
VALUES ($1, $2, $3, $4, $5, 'pending')
ON CONFLICT (upload_id) DO NOTHING`
if _, err := tx.ExecContext(ctx, insert, uploadID, tenantID, key, name, contentType); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return signer.SignUpload(ctx, key, contentType)
}
This function is not a complete upload service. It leaves authentication, filename policy, transaction rollback, maximum size, and the provider-specific signer to the surrounding application, which is where those decisions belong. In real code, I would also make the commit and signing boundary explicit: if signing fails after the row commits, the pending record is a retryable work item, not evidence that a report is ready.
Short URLs help, but they do not eliminate authorization risk. Anyone who obtains a signed URL can generally use it until it expires, so the URL should be scoped to one key and operation and live only as long as the browser interaction requires. The application must never log the complete URL as if it were harmless request metadata.
Why is Postgres the control plane for object storage?
Postgres can answer questions that object storage cannot answer cleanly: which tenant owns this report, is it visible yet, which generated revision is current, and should this customer see it after a subscription or membership change? Those are product questions. A bucket is a byte store with metadata and listing behavior; it is not the tenant authorization model.
The database also gives operations a useful queue. A job can find pending uploads older than the allowed window, mark them expired, and ask a reconciliation process to inspect the corresponding keys. The reverse check is useful too: an object with no matching row should be quarantined or removed according to retention policy, rather than silently becoming customer-visible. This is where observability earns its keep: measure time spent in pending, completion rate, signed-read denials, and reconciliation volume, then put an SLO around the customer-visible workflow instead of only the storage request.
Generated reports often need revisions. Give each accepted revision a new key and a new row, then point a small application record at the current revision. That preserves an audit trail and avoids making an in-place overwrite carry too many meanings. If the product needs immutable retention, legal holds, or storage-level version history, this design needs a storage system and policy that provide those properties; a Postgres row alone cannot manufacture them.
What does a good browser upload path measure?
The browser should show progress from the upload API's progress events, but the progress bar is not the source of truth. A completion response from the application is the useful boundary for the UI. A page reload should ask the service for the current row and render pending, ready, expired, or deleted from durable state.
Test the edges directly. Cancel a transfer. Repeat the completion request. Submit the wrong content type. Expire a URL before the browser uses it. Remove a tenant's access between issuing a download URL and the next request. The expected result is a safe state transition or a clear denial, never a public fallback URL.
The delivery SLO should include authorization latency, signing latency, storage transfer completion, and the time required for cleanup. Teams frequently monitor only the storage provider's availability while their customer is waiting on an application callback. That produces a healthy infrastructure graph and a broken report page.
The buy-vs-build boundary is mostly an SLO and on-call decision, not a badge of architectural sophistication.
| Choice | It fits when | The cost you accept |
|---|---|---|
| Managed object storage | You need durable byte delivery and a small platform team | Provider policy, data placement, and retention contracts become dependencies to review. |
| Self-hosted object storage | You have a clear recovery objective and staff for capacity planning | Durability, upgrades, replication, and incident response become your SLO obligations. |
| Application-server uploads | Files are small and the workflow has low volume | The application tier absorbs bandwidth, retries, and transfer concurrency. |
The table is a decision aid, not a ranking. A managed service can still be the wrong choice for a legal-hold requirement that its storage contract cannot express, while self-hosting can be the wrong choice for a team that cannot rehearse recovery.
Where should this pattern stop?
The catch is that direct browser transfers add coordination. You need CORS policy, a completion protocol, expired-row cleanup, and a reconciliation job. A small internal tool with tiny files may be easier to operate by sending bytes through its application server, especially when its access model is simple and its traffic is low.
This pattern is also a poor fit for public assets, collaborative editing with concurrent writers, or an archive that requires immutable retention and legal holds. Choose a design whose storage contract includes those guarantees, or keep the application-server path when it materially reduces the state machine. I'm not sure there is one universal “best” object storage choice; your mileage may vary with compliance, recovery objectives, network placement, and the team's on-call capacity.
The decision rule is therefore narrower than “use signed URLs.” Use direct browser upload when the payload is large enough to make application proxying expensive, the object must remain private, and the team is willing to own a database-backed state machine. Keep Postgres authoritative, treat signed links as expiring capabilities, and make the failure paths part of the SLO.
Top comments (0)