ADR-0005: Store YourBOX Video Assets In Flysystem Abstracted Object Storage
Status
Section titled “Status”Accepted
Context
Section titled “Context”YourBOX currently writes uploaded videos and thumbnails to a local cache/upload/
directory on the application container, then stores the absolute filesystem path in the
files table. Two sample rows in yourbox.sql reference
/home/anthony/Documents/homelab_devsecops/YourBOX/cache/upload/..., so the data layer
carries a developer workstation path. This pattern breaks horizontal scaling, prevents the
application from running with a read only filesystem, makes backups awkward and ties the
application to a single deployment topology.
The storage layer must support the patterns of large streaming providers: redundancy through replication, the option of sharding by content identifier, and a clean separation between content addressing and physical storage. The same codebase deploys to the VPS smoke test, the Azure environment and the homelab Kubernetes cluster without rewriting the storage path.
Storing video binaries in the database is rejected. Object storage is the standard answer for this access pattern; the database holds metadata only.
Decision
Section titled “Decision”YourBOX writes video assets through a Symfony service that wraps Flysystem. The interface
exposed to the application has methods upload, download, delete, getSignedReadUrl
and exists. The implementation is one Flysystem adapter selected by the
YOURBOX_STORAGE_ADAPTER environment variable.
The supported adapters are:
s3for MinIO in the homelab and on the VPS smoke test. MinIO runs as a single node in smoke environments and as an erasure coded distributed cluster in the homelab Kubernetes cluster. The sames3adapter targets either deployment.azure_blobfor the Azure deployment target. The container is created by Terraform and the application reads the connection string from OpenBao via the External Secrets Operator.localfor local development without object storage. This is the only adapter that touches the container filesystem, and it is mounted on a host volume so the data is not lost on container restart.
The database stores a logical key in the form videos/YYYY/MM/<ulid>/source.mp4. The
filesystem path column is removed from the schema during the PostgreSQL migration in
ADR-0003. Thumbnails and HLS renditions follow the same key prefix.
A Symfony Messenger consumer handles transcoding. On upload the application stores the
original file under the source key, then dispatches a TranscodeVideo message. The
consumer runs FFmpeg, writes the HLS manifest and segments under
videos/YYYY/MM/<ulid>/hls/index.m3u8, then updates the database row with the manifest
key and duration. Until the transcoding completes, the video is hidden from the catalog.
The HLS manifest is served either directly by the object store (Azure Blob with public read, or MinIO with a Traefik fronted path) or proxied through nginx with byte range support. The application generates signed URLs valid for one hour for any read access that requires authentication.
Consequences
Section titled “Consequences”- The application container is stateless. The filesystem can be read only at runtime in production, which simplifies image hardening and Kubernetes security context.
- The same code path serves the VPS smoke test, the Azure environment and the homelab Kubernetes cluster. Switching adapters is a configuration change, not a code change.
- Backups become the responsibility of the storage layer. MinIO uses object replication or versioning. Azure Blob uses geo redundant storage. The runbook for restoring a video documents the path for each backend.
- Transcoding is asynchronous. The video catalog must indicate pending transcoding states so that the user interface does not show a broken playback link.
- Signed URLs leak the storage backend identity to the browser. This is acceptable for HLS segment delivery but is called out in the security model documentation.
- An attacker who gains write access to one adapter cannot reach the other adapter. Cross environment compromise requires breaching two distinct credential paths.