← Documentation

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.