Files
web_sport/docs/adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md

1.5 KiB

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.