38 lines
1.5 KiB
Markdown
38 lines
1.5 KiB
Markdown
# ADR-0009: Media in S3-compatible storage, metadata in PostgreSQL
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-11
|
|
|
|
## Context
|
|
|
|
Product photography is the bulk of an apparel store's bytes. Storing binaries in
|
|
PostgreSQL bloats backups, makes replication slow, and forces image delivery through the
|
|
application tier.
|
|
|
|
## Decision
|
|
|
|
Bytes live in Cloudflare R2 (MinIO locally). PostgreSQL stores only metadata: object
|
|
key, MIME type, dimensions, size, alt text and a small base64 blur placeholder.
|
|
|
|
Browsers upload directly to the bucket using a short-lived presigned URL, so files never stream
|
|
through the API. Public URLs are composed at read time from `STORAGE_PUBLIC_URL + storageKey`,
|
|
never stored — so changing bucket, CDN domain or provider is a config change, not a data
|
|
migration.
|
|
|
|
Uploads are restricted by a MIME allow-list, and generated keys are date-partitioned UUIDs that
|
|
never echo the user's filename.
|
|
|
|
## Consequences
|
|
|
|
Database backups stay small and fast. Images are served by a CDN at the edge. The
|
|
API scales on CPU, not bandwidth.
|
|
|
|
The cost is eventual-consistency between the two stores: a failed upload can leave an orphaned
|
|
row, and a deleted row can leave an orphaned object. A periodic reconciliation job is the
|
|
accepted mitigation; two-phase commit across a database and object storage is not worth it.
|
|
|
|
## Alternatives considered
|
|
|
|
`bytea` columns — rejected for the reasons above. Serving uploads through the API
|
|
— rejected: it makes the API a bandwidth bottleneck and a timeout risk on large files.
|