Storehouse — Files for Phoenix

Files for Phoenix: uploads checked by their bytes, stored under safe keys, and served safely.

Photos people upload, a document's attachments, the exports your app makes. Storehouse checks each file by its contents, not its name, stores it on the local disk or in S3 (or R2, MinIO, Tigris), and serves it so it can never run a script on your app's origin.

Early release. Before 1.0, a minor version may change the API.

Install

You need Elixir 1.17+. Storehouse.Live also needs Phoenix LiveView 1.1+; the rest works in any Plug app.

Add Storehouse to the deps in mix.exs:

{:storehouse, "~> 0.2"}

On Elixir 1.17, add {:jason, "~> 1.4"} too. 1.18 and later have their own JSON.

Your first file

Tell Storehouse where to keep files, and serve them from your endpoint:

# config/runtime.exs
config :storehouse, Storehouse.Local, root: "/var/lib/my_app/uploads"

# lib/my_app_web/endpoint.ex, before the router
plug Storehouse.Local.Plug

Store a file:

{:ok, file} = Storehouse.store("priv/seeds/palette.png")

file
#=> %Storehouse.File{
#     key: "2026/10/0b6…-palette.png",
#     url: "/uploads/2026/10/0b6…-palette.png",
#     name: "palette.png",
#     content_type: "image/png",
#     bytes: 48_211,
#     width: 1600,
#     height: 1000,
#     preview: nil
#   }

Keep it in a record:

schema "users" do
  field :avatar, Storehouse.Ecto.File
end

user
|> User.changeset(attrs)
|> Storehouse.Ecto.put_file(:avatar, upload, accept: Storehouse.Check.images(), max_bytes: 5_000_000)
|> then(&Storehouse.Ecto.update(Repo, &1))

A list field ({:array, Storehouse.Ecto.File}) holds many, with add_files/4 and remove_file/3. The Ecto guide has both, and how each Active Storage call maps to Storehouse.

Or store the files of a LiveView upload:

{socket, results} = Storehouse.Live.consume(socket, :photos, accept: Storehouse.Check.images())

The Stores guide sets up S3, development and tests.

What you get

  • Files checked by their bytes. Images, camera photos (HEIC, AVIF, DNG), PDF, video, ZIP and Office files. An HTML page renamed .png is refused or sent as a download. → Serving
  • Your choice of store. The local disk, S3 and the stores that speak its API, or your own. Several at once, and files copied between them. → Stores
  • Uploads straight to the bucket. Signed in your app, checked when they land. Very large files go in parts and resume after a reload. → Direct uploads
  • Files in Ecto records. A column, no join table. Temporary until the record is saved, deleted once it no longer has them. → Ecto
  • Private files. Signed URLs that expire, or files streamed through your app after it checks the person. → Private files
  • Variants and placeholders. Thumbnails and srcset sizes with libvips or imgproxy, and a tiny preview while a photo loads. → Variants, After the upload
  • Any frontend. JSON endpoints and a JavaScript client for React, Vue or a native app, and tus for Uppy and mobile clients. → Uploads from any client
  • Work in the background. Analysis, deletions and mirrors run on Oban, or on a process of your app's. → Work out of the request
  • Tests without a store. An in-memory store, safe under async: true, with assertions. → Stores

The full list is in Features.

Guides

Stores · Serving · LiveView · Direct uploads · Uploads from any client · Private files · Ecto · After the upload · Variants · Work out of the request · Features

How Storehouse compares

The Elixir libraries that keep an app's files, as their documentation and code describe them in October 2026. A correction is welcome as an issue.

Storehouse 0.2Waffle 2.0Capsule 1.0phx_media_library 0.10attached 0.2
Type checked from the bytesYesNo (extension)NoPartlyPartly
Safe serving headersYesNoNoNoNo
CDN cache headers and purgesYesNoNoPartlyNo
Private files through the appYesNoNoYesNo
Uploads straight to the storeYesNoNoYesPartly
Streamed uploads, no temporary fileYesNoNoNoNo
Temporary until savedYesNoPartlyPartlyNo
ChecksumsYesNoNoYesYes
Files from a URLYesYesNoNoNo
LiveView uploadsYesNoNoPartlyNo
Reading files backYesYesYesYesYes
Your own metadataYesNoYesYesYes
Photo metadata edited without re-encodingYesNoNoNoNo
Several stores at onceYesPer uploaderPer fileYesNo
Background processingYesNoNoYesYes
VariantsYesAt uploadNoYesOn demand
PlaceholdersYesNoNoYesNo
A table of filesOptionalNoNoYesYes
Duplicate uploads detectedYesNoNoNoNo
JavaScript client, non-LiveView endpointsYesNoNoPartlyNo
Resumable and multipart uploadsYesNoNoNoNo
In-memory store for testsYesNoYesYesNo
DependenciesPlug, Req, TelemetryReq, ImageMagickNoneSeveralOban, Vix

Roadmap

  • A PDF's first page and a video's poster as previews; video and audio length.
  • Finding files no record uses any more.
  • Azure and native Google Cloud Storage. Google Cloud Storage works through its S3 API today.

Development

mix deps.get
mix test
mix credo --strict
mix dialyzer
node --test test/js/*.test.mjs

License

MIT. See LICENSE.