Storage
Miru stores every original exactly as uploaded, plus three generated variants per image and the HLS segments for each video. It writes through one storage module with two backends.
Which backend is in use
There is no STORAGE_BACKEND setting. Miru picks S3 if it has been given
credentials and an endpoint, and local disk otherwise:
if R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY and an endpoint are all present
→ S3
else
→ local disk
This is deliberate. A missing credential falls back to something that works
rather than raising at boot — a raise in runtime.exs is a crash loop on any
cluster secret gap, and a crash loop is much harder to diagnose than uploads
landing in the wrong place.
The consequence is worth stating plainly: if you intend to use object storage and one variable is misspelled, Miru will quietly write to local disk instead. Check the logs at boot, or upload one file and look at where it went.
S3-compatible object storage
Any S3-compatible service. Cloudflare R2 is what the hosted instance uses, and
the variable names reflect that, but GARAGE_* equivalents are read as
fallbacks and MinIO, Garage, Backblaze B2 and AWS S3 all work.
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=miru
# Either an explicit endpoint...
R2_ENDPOINT=https://s3.example.com
# ...or an R2 account id, from which the endpoint is derived
R2_ACCOUNT_ID=...
R2_ENDPOINT must be an absolute HTTP or HTTPS URL. This one does raise at
boot if it is malformed, because a half-parsed endpoint produces requests to a
host that does not exist, and failing immediately is clearer.
R2 differs from AWS S3 in two ways Miru already handles: the region is always
auto, and the endpoint is per-account rather than per-region.
Bucket layout
originals/{user_id}/{media_id}/{filename}
variants/{user_id}/{media_id}/{thumb,medium,large}.jpg
hls/{user_id}/{media_id}/...
Nothing in the bucket should be publicly readable. Miru serves media through the application, which authorises every request against the media's visibility — holding a storage path is not sufficient to read a private photograph.
Local disk
LOCAL_STORAGE_DIR=/var/lib/miru/uploads
Defaults to priv/static/uploads, which is fine for development and wrong for
anything else — it lives inside the release directory and will be replaced on
upgrade.
Point it at a real volume, make sure it is backed up, and make sure the process user (UID 1000 in the container) can write to it.
Paths are contained: a request cannot escape the storage directory with .. or
an absolute path. There are tests that assert this rather than a comment
claiming it.
Backups
Two things need backing up, and they are not equally replaceable.
The files. Originals cannot be regenerated from anything. Variants can — a rebuild of every variant is a long job but not a lost one.
The database. Which file belongs to whom, album membership, posts, follows, and the metadata that makes search work. Without it, the files are a directory of anonymous blobs.
Back up both, and restore both at least once into a scratch environment. A backup nobody has restored is not a backup.