This guide sets up Fil in an application with two disks, uploads and backups. They're local directories in
development, in memory in tests and S3 buckets in production.
Adding the dependency
Add fil to your dependencies in mix.exs:
def deps do
[
{:fil, "~> 0.1"}
]
endThen fetch it:
mix deps.get
In a script or a Livebook, Mix.install/1 is enough:
Mix.install([{:fil, "~> 0.1"}])
disk = Fil.disk(adapter: Fil.Adapter.Local, root: "storage")A module for your disks
A disk is a plain value, so your application needs a place that builds it. One function per disk works well:
defmodule MyApp.Storage do
@moduledoc "The disks MyApp stores files on."
@doc "Returns the uploads disk."
def uploads do
disk_from_config(:uploads)
|> Fil.Plugin.ContentType.attach()
end
@doc "Returns the backups disk."
def backups do
disk_from_config(:backups)
end
defp disk_from_config(name) do
Application.fetch_env!(:my_app, __MODULE__)
|> Keyword.fetch!(name)
|> Fil.disk()
end
endPlugins that every environment needs are attached here. Fil.Plugin.ContentType sets the content type from the file
extension, so browsers can display uploads. Plugins that only some environments need go into the config instead (see
Signed URLs).
Building a disk only validates the options (no network, no process), so it's fine to build it on every call.
The rest of the application doesn't know which storage is behind a disk:
disk = MyApp.Storage.uploads()
Fil.write(disk, "avatars/#{user.id}.png", png)Fil doesn't need this module. It has no application config of its own, so read the options from anywhere you like.
Configuration
Development stores files in local directories:
# config/dev.exs
config :my_app, MyApp.Storage,
uploads: [adapter: Fil.Adapter.Local, root: "priv/storage/uploads"],
backups: [adapter: Fil.Adapter.Local, root: "priv/storage/backups"]Add the directory to .gitignore:
/priv/storage/Tests keep files in memory (see Testing):
# config/test.exs
config :my_app, MyApp.Storage,
uploads: [adapter: Fil.Adapter.Memory, root: "uploads"],
backups: [adapter: Fil.Adapter.Memory, root: "backups"]Production uses S3. Put it in config/runtime.exs, so the credentials are read from the environment when the release
starts:
# config/runtime.exs
if config_env() == :prod do
s3 = [
adapter: Fil.Adapter.S3,
region: "eu-central-1",
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
]
config :my_app, MyApp.Storage,
uploads: s3 ++ [bucket: "my-app-uploads"],
backups: s3 ++ [bucket: "my-app-backups"]
endFil.Adapter.S3 lists every option. Keep the disks out of config/config.exs: Config merges keyword lists, so a
root: set there would end up in the S3 options too, and Fil.disk/1 raises on options the adapter doesn't know.
Signed URLs
A signed URL lets a browser download or upload a file directly:
disk = MyApp.Storage.uploads()
Fil.signed_url(disk, "avatars/1.png", method: :put, expires_in: 300)S3 signs its own URLs. Local and memory disks can't, so Fil.Plugin.URL signs them and Fil.Plug serves them from
your application. Production doesn't need the plugin, so it goes into the development and test config. Each entry
under plugins: names the plugin's callback and its options, and Fil.disk/1 attaches them before the storage module
attaches its own:
# config/dev.exs
uploads: [
adapter: Fil.Adapter.Local,
root: "priv/storage/uploads",
plugins: [
{Fil.Plugin.URL, :call, base_url: "http://localhost:4000/uploads", secret: "a development secret of 32 bytes"}
]
]Then serve the URLs in your endpoint, above Plug.Parsers, at the path of base_url::
# lib/my_app_web/endpoint.ex
plug Fil.Plug, at: "/uploads", disk: &MyApp.Storage.uploads/0In production, uploads has no Fil.Plugin.URL, so its URLs go to S3 and the plug lets every request pass.
Fil.Plug also serves public files without a signature, see its documentation.
Testing
Fil.Adapter.Memory keeps files in a store that belongs to the test process. Check one out in every test that touches
files, for example in your DataCase or ConnCase:
setup do
Fil.Adapter.Memory.checkout()
endEvery test starts with an empty store, so tests can run with async: true and there's nothing to clean up. Tasks
and LiveViews started by the test use its store too, and so does Fil.Plug in a Phoenix.ConnTest request. Any other
process has to be allowed in:
Fil.Adapter.Memory.allow(self(), MyApp.Thumbnailer)The adapters differ only in edge cases, listed in Where the adapters differ.