Short answer: for a startup app that stores user-uploaded documents, keep objects private, make the application authorize every upload and download, and issue a narrowly scoped signed URL only after that decision.
The tempting design is a public-looking upload endpoint plus a filename in a database. It works until a user guesses another tenant's key, a support ticket contains a live URL, or a document preview turns a download capability into an accidental sharing mechanism. The storage service is not the policy engine. Treating it as one makes the quiet failure modes hard to see.
For a small platform team, the useful definition of simple is smaller than the usual one: fewer state transitions that can disagree, fewer credentials in browsers, and a recovery path that can be exercised without asking the person on call to reconstruct intent from object names. Bytes are cheap to move until they are not. Authorization mistakes are expensive immediately.
Pause there.
How should a startup app store user-uploaded documents with private access and signed URLs?
Split the system into a control plane and a data plane. The application owns the control plane: tenant membership, document state, retention, the original display name, expected media type, and an opaque storage key generated from trusted identifiers. The object store owns the bytes. A browser requests permission from the application, receives a short-lived capability for one operation, transfers bytes directly, then reports completion. A download follows the same order in reverse: authorize against current metadata, then mint a capability.
That ordering is the invariant. A signed URL is a bearer capability, not a user session. Anyone who obtains it can use it while its constraints remain valid, so it does not belong in durable database fields, analytics events, or logs. It also should not be derived from a browser-supplied filename. Keep the name as display metadata; generate the key server-side.
There is a small but consequential HTTP detail on the read path. Content-Disposition: attachment asks the browser to download a response rather than try to render it, and its filename parameter supplies a suggested name. MDN documents both the disposition behavior and filename handling. That header is useful for documents, but it is presentation policy, not access control.
The incident lesson is a state-machine lesson
Consider a bounded production scenario: a user starts a document upload, loses connectivity after the bytes arrive, and retries the application completion call later. The store may contain the object while the application still sees a pending document. If a download path trusts object existence alone, it can expose an unreviewed or unscanned document. If completion blindly creates a new row, retries produce conflicting metadata. Neither outcome needs an exotic storage failure.
The preventative move is to make the database state authoritative and completion idempotent. Create a pending document record before issuing the upload capability. On completion, check that the caller still has access to that record, validate the properties the application requires, and transition the record once. Readers can receive a URL only for an available record. A periodic reconciler can account for abandoned pending records and objects with no matching record; it should report differences, not silently invent ownership.
Here is the narrow boundary in Go. The example intentionally leaves the signer generic so the application contract does not leak a particular storage SDK into business code.
package documents import ( "encoding/json" "net/http" "time" ) type Principal struct { TenantID string UserID string } type Document struct { ID string TenantID string Key string State string } type Store interface { FindDocument(id string) (Document, error) CanRead(Principal, Document) (bool, error) } type Signer interface { SignDownload(key string, expiresAt time.Time) (string, error) } func Download(store Store, signer Signer, now func() time.Time, p Principal, id string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { doc, err := store.FindDocument(id) if err != nil || doc.TenantID != p.TenantID || doc.State != "available" { http.Error(w, "not found", http.StatusNotFound) return } allowed, err := store.CanRead(p, doc) if err != nil || !allowed { http.Error(w, "forbidden", http.StatusForbidden) return } url, err := signer.SignDownload(doc.Key, now().Add(5*time.Minute)) if err != nil { http.Error(w, "could not create download", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"url": url}) } } The five-minute lifetime is policy, not a universal constant. Choose it from the sensitivity of the documents and observed transfer time, including clock skew. I'm not sure a single window serves slow mobile connections and office networks equally well; measure signing-to-completion latency before standardizing it. Log the authorization decision and document identifier, but redact the URL query string. One leaked capability is enough.
Capacity planning has four curves, not one storage number
Capacity planning for documents starts with retained bytes, then gets more realistic: new bytes per day, retained bytes after deletion and versioning, requests by operation, and bytes transferred to readers. Retry amplification belongs in the model as well. A 200 MiB upload retried twice crosses the boundary three times even when only one final object remains. Preview generation can amplify reads again when derived images are not cached.
This is where an apparently tidy architecture often acquires an operational blind spot. Direct browser-to-store transfer means an application request can finish successfully while the document itself remains pending, and a request counter at the application edge will not describe actual upload attempts, completed byte volume, or the tail of client-side transfer latency. The metrics need a correlation identifier carried from the authorization record through completion, then aggregated by tenant, document class, and outcome. Without that, a rise in pending documents might be a client release, a network population, a too-short capability lifetime, or an abandoned workflow; the on-call engineer gets a symptom without a decision path. Preserve the raw events long enough to compare those explanations, while keeping capability-bearing query strings out of the event payload. It is mundane work, which is precisely why it is often omitted from early designs.
The resulting SLOs should follow the architecture. The application can own availability and latency for authorization and URL issuance; upload completion needs its own indicator because the data path bypasses application servers. Track pending-to-available age, authorization denials, capability issuance volume, completion latency, checksum mismatches, delete backlog, and egress by tenant or geography. This is more actionable than a dashboard that only shows total stored GiB.
Published pricing pages should be treated as inputs to a scenario model, not a conclusion. Backblaze's pricing page, for example, separates storage and transfer terms, which illustrates why a storage-only forecast is incomplete. Record the retrieval date in the internal estimate, replay a representative operation mix, and test a growth case plus a retry-heavy case. The point is not to predict a perfect invoice. It is to find which workload change would exhaust the budget or the service objective first.
What belongs in a buy-versus-build decision?
| Decision axis | Managed object storage | Self-operated object storage |
|---|---|---|
| On-call work | The provider operates the storage fleet; the team still owns access policy and recovery tests | The team owns disk failure, replication, upgrades, capacity headroom, and incident response |
| Capacity | Plan request and transfer limits alongside retained data | Reserve headroom and plan rebalancing before disks fill |
| Control | Bound by exposed regions, policies, and interfaces | Greater placement control with a larger operations surface |
| Portability | Preserve it through opaque keys, metadata, checksums, and tested export | Preserve formats and clients, while continuing to own operations |
The catch is that direct signed uploads trade application bandwidth for less direct observation of byte transfer. A proxy is reasonable for small, high-sensitivity documents when inline inspection, uniform audit records, or strict rate controls matter more than horizontal bandwidth. Keep both paths behind the same document state machine so a policy choice does not rewrite the authorization model.
Self-operation is not suitable when a startup has no named storage operator, no tested restore procedure, or no budget for redundant capacity. Stick with a managed service when its controls satisfy the threat model and on-call capacity is the scarce resource. Conversely, self-operation can be justified by data-locality requirements, predictable scale, or a control requirement that managed interfaces cannot meet. Compatibility with an object protocol helps migration, but it is not an exit plan; a restore exercise and a portable metadata inventory are the proof.

