Short answer: for a property-management SaaS that must process receipts and retain the original file, use object storage for the receipt originals and choose database backups according to the recovery target. Managed database backups are the least complicated route to point-in-time recovery; exported database files in object storage are easier to index, move, and retain under an application-controlled policy. The cheapest design is only the cheapest after a restore drill succeeds.
The decision is about deletion and recovery ownership, not a storage leaderboard. A receipt has at least two recovery concerns: the binary original and the row that says which property, tenant, amount, and processing result it belongs to. Restoring one without the other creates an audit problem that a green backup job won't reveal.
It fails.
Receipt retention is a governance control
Consider a bounded production event: a deletion request arrives for a tenant, while an auditor has a valid retention requirement for the original receipt. The application must distinguish an ordinary user-visible record from an immutable audit copy, apply the legal policy, and leave an operator able to prove what happened. A nightly export does not answer those questions by itself.
The invariant is straightforward: every backup object needs an owner, a retention class, a creation timestamp, an integrity check, and a restore recipe. The database index should point to the object key and record the database engine and schema revision used to produce the related export. That small amount of metadata is more useful during a 02:00 restore than a folder full of timestamps.
There is a second failure mode: a restore can be technically successful and still be wrong. A PostgreSQL dump restored into a different extension set, or a MySQL export interpreted with the wrong character set, can pass a process check while changing application behavior. The rehearsal must run application checks against representative receipts, including the original filename, content type, tenant boundary, and processing status. That check should also cover the less visible joins: the recovered row must resolve to the recovered object key, the tenant filter must reject another property's receipt, and the processing worker must not enqueue the same receipt twice. If any of those checks is missing, the team has tested file movement rather than recovery.
Keep the deletion path separate from the backup writer. A request that removes a receipt from the serving database must not silently delete an audit copy before its retention deadline. Conversely, a retention policy must not become an excuse to keep data forever. Record the decision and the policy version; make the deletion job observable and idempotent.
How should a SaaS team set reliability targets for object storage and managed database backups?
Set recovery contracts before comparing implementations. The useful questions are: can the team restore one tenant, can it recover to a precise point in time, how is the original receipt found, and who owns deletion when the policy changes?
| Choice | Strong fit | Restore shape | Main trade-off |
|---|---|---|---|
| Object storage for originals and exported database files | Explicit retention, portable archives, tenant-oriented lookup | Select an indexed object, provision a database, import, then validate | The team owns indexing, integrity checks, encryption policy, and orchestration |
| Managed database backups | Point-in-time recovery and a small on-call team | Select a time or snapshot, restore an instance, then replay application validation | Provider-specific retention and restore semantics reduce portability |
| A combined design | Receipts are files while transactional state lives in PostgreSQL or MySQL | Restore the database and object set using one recorded recovery point | Two systems must agree on timestamps, deletion state, and access control |
For property management, the combined design is usually the sensible starting shape: keep receipt originals in private object storage, keep searchable metadata in the database, and back up both with a documented relationship. That is not a claim that one storage type wins. It is a recognition that a relational database is a poor place to make large binary retention easy to audit, while a file store is a poor place to enforce tenant joins and transactional state. The boundary is useful — it makes the retention decision explicit — but it also creates a coordination obligation: the database record cannot be marked archived until the object is verifiably present, and an object cannot be purged while an active record still points to it.
The database backup choice follows the SLO. If the service needs a narrow RPO and a predictable point-in-time rollback, managed recovery may justify its loss of control. If the requirement is a daily archive with a long retention period and a tenant-level export, logical dumps in object storage may fit better. Capacity planning should include archive growth, restore bandwidth, temporary database capacity, and the time needed to validate every recovered tenant.
Do not call a backup cheap until the calculation includes the operator minutes and the temporary restore environment. I'm not sure which side wins for a particular workload without the receipt volume, average object size, retention schedule, and target RPO/RTO. Measure those inputs instead of borrowing a generic cost claim.
Code for the restore contract in PostgreSQL and MySQL
The write path should make an incomplete backup visible. First persist the receipt and its metadata in a transaction. Then upload the original to a private key that cannot be reused, verify the stored length and checksum, and mark the metadata as archived. If the upload fails, the record remains pending and a retry can safely continue; it does not pretend that a database row is an audit copy.
The backup job takes a consistent database export, records the engine version and schema revision, and uploads the export beside a manifest. The manifest links the database recovery point to the object-storage prefix. A restore controller reads that manifest, obtains the database at the requested point, retrieves the matching receipt set, and runs checks before allowing traffic.
Here is a deliberately provider-neutral Go sketch for the object part of that contract. The storage adapter can use a standards-based object API, a filesystem-backed test double, or another implementation; the recovery logic does not need to know which one is underneath.
package backup
import (
"context"
"crypto/sha256"
"fmt"
"io"
"time"
)
type ObjectStore interface {
Put(ctx context.Context, key string, body io.Reader, size int64, checksum [32]byte) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
}
type Manifest struct {
Key string
CreatedAt time.Time
DatabaseEngine string
SchemaRevision string
Checksum [32]byte
}
func ArchiveReceipt(ctx context.Context, store ObjectStore, key string, r io.Reader, size int64, engine, schema string) (Manifest, error) {
body, err := io.ReadAll(r)
if err != nil {
return Manifest{}, fmt.Errorf("read receipt: %w", err)
}
if int64(len(body)) != size {
return Manifest{}, fmt.Errorf("receipt size changed: expected %d, got %d", size, len(body))
}
sum := sha256.Sum256(body)
if err := store.Put(ctx, key, bytesReader(body), size, sum); err != nil {
return Manifest{}, fmt.Errorf("archive receipt: %w", err)
}
return Manifest{
Key: key, CreatedAt: time.Now().UTC(), DatabaseEngine: engine,
SchemaRevision: schema, Checksum: sum,
}, nil
}
func bytesReader(body []byte) io.Reader { return &byteReader{body: body} }
type byteReader struct { body []byte; offset int }
func (r *byteReader) Read(p []byte) (int, error) {
if r.offset == len(r.body) { return 0, io.EOF }
n := copy(p, r.body[r.offset:])
r.offset += n
return n, nil
}
In a production implementation, the adapter should make the key creation-only and preserve the checksum in the manifest. The example buffers the receipt to keep the contract visible; large originals should use a streaming hash and a bounded temporary file instead. A restore test then downloads the object, hashes it, compares the manifest, and checks that the recovered application can display the same receipt.
The short sentences matter here. Restore twice. Delete nothing during the rehearsal. Verify the result.
When should the retention architecture change?
Object storage is not suitable as the only recovery mechanism when the SLO requires continuous point-in-time database recovery and the team cannot operate an import pipeline. Choose managed database backups when the database provider's recovery operation is the only path the on-call team can rehearse reliably. Keep the object-storage archive for originals only if that division is covered by a tested manifest and retention policy.
Managed recovery is a poor fit when the business needs a portable tenant archive, an independently inspectable file, or a restore into a different database environment. In that case, export a logical backup and retain it with the original files, accepting the work of version compatibility and import validation. Stick with a combined approach when the audit requirement applies to the original receipt rather than merely to the row containing its extracted fields.
Deletion deserves its own review. Versioning or retention controls can prevent accidental replacement, but they do not decide whether a particular tenant's receipt should be deleted. The application needs a policy decision, an authorized operation, and a record of the result. Cache behavior also matters for any administrative download: a signed response should be private and should not be made reusable by a shared cache.
Evaluate the recovery workflow before committing
Before approving the design, ask an engineer who did not write the backup job to perform three drills. Restore a full database. Restore one tenant and its receipt originals. Exercise a deletion request while an audit hold is active. Capture elapsed time, operator steps, failed checks, and the amount of temporary capacity used.
The pass criteria belong in the SLO review: measured RPO, measured RTO, complete tenant isolation, checksum agreement, schema compatibility, and an auditable deletion result. A failed rehearsal is useful data; an untested green status is not evidence.
The decision rule is conditional. Use managed database recovery for the simplest point-in-time path when provider coupling is acceptable. Use object storage for portable originals and exports when retention control and tenant-level movement matter. Use both when receipts and relational state have different recovery shapes, then rehearse the join between them until it is boring.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://csrc.nist.gov/pubs/sp/800/66/r2/final
Top comments (0)