# 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.