An Ash extension for file storage and attachments.
Add ash_storage to your list of dependencies in mix.exs:
def deps do
[
{:ash_storage, "~> 0.1.0"}
]
endAshStorage requires three resources: a blob resource to store file metadata, an attachment resource to link blobs to records, and one or more host resources that declare attachments.
defmodule MyApp.StorageBlob do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshStorage.BlobResource]
postgres do
table "storage_blobs"
repo MyApp.Repo
end
blob do
end
attributes do
uuid_primary_key :id
end
endFor a single-parent use case with proper foreign keys:
defmodule MyApp.StorageAttachment do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshStorage.AttachmentResource]
postgres do
table "storage_attachments"
repo MyApp.Repo
end
attachment do
blob_resource MyApp.StorageBlob
belongs_to_resource :post, MyApp.Post
end
attributes do
uuid_primary_key :id
end
endFor attachments shared across multiple resource types, declare multiple belongs_to_resource entries (foreign keys will be nullable):
attachment do
blob_resource MyApp.StorageBlob
belongs_to_resource :post, MyApp.Post
belongs_to_resource :comment, MyApp.Comment
endFor fully polymorphic attachments (using record_type/record_id string columns instead of foreign keys), omit belongs_to_resource entirely:
attachment do
blob_resource MyApp.StorageBlob
enddefmodule MyApp.Post do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshStorage],
otp_app: :my_app
storage do
service {AshStorage.Service.Disk, root: "priv/storage", base_url: "/storage"}
blob_resource MyApp.StorageBlob
attachment_resource MyApp.StorageAttachment
has_one_attached :cover_image
has_many_attached :documents
end
# ...
endThis automatically adds:
has_one :cover_image/has_many :documentsrelationships to load attachments- A
cover_image_urlcalculation for eachhas_one_attached
{:ok, %{blob: blob}} =
AshStorage.Operations.attach(post, :cover_image, file_data,
filename: "photo.jpg",
content_type: "image/jpeg"
)For has_one_attached, attaching replaces any existing attachment (the old file is purged). For has_many_attached, each attach appends.
post = Ash.load!(post, :cover_image)
post.cover_image.blob.filename
#=> "photo.jpg"
post = Ash.load!(post, :cover_image_url)
post.cover_image_url
#=> "/storage/a81bf21e2442..."
post = Ash.load!(post, documents: :blob)
Enum.map(post.documents, & &1.blob.filename)
#=> ["report.pdf", "notes.txt"]# Detach (remove link, keep file)
AshStorage.Operations.detach(post, :cover_image)
# Purge (remove link, blob record, and file)
AshStorage.Operations.purge(post, :cover_image)
# For has_many_attached, specify which blob
AshStorage.Operations.detach(post, :documents, blob_id: blob.id)
AshStorage.Operations.purge(post, :documents, blob_id: blob.id)
# Purge all documents
AshStorage.Operations.purge(post, :documents, all: true)Control what happens to attachments when a record is destroyed:
storage do
has_one_attached :cover_image # default: dependent: :purge
has_many_attached :documents, dependent: :detach # keep files, remove links
has_many_attached :logs, dependent: false # do nothing
endFile deletion happens outside the database transaction, so a failed file delete won't roll back the record destroy.
Soft destroy actions (where action.soft? is true) skip dependent attachment handling entirely.
storage do
service {AshStorage.Service.Disk, root: "priv/storage", base_url: "/storage"}
endstorage do
has_one_attached :avatar, service: {AshStorage.Service.S3, bucket: "avatars"}
endOverride the service at runtime using application config. This requires otp_app on the resource:
# The resource
defmodule MyApp.Post do
use Ash.Resource,
extensions: [AshStorage],
otp_app: :my_app
# ...
endOverride the resource-level service:
# config/test.exs
config :my_app, MyApp.Post,
storage: [service: {AshStorage.Service.Test, []}]Override a specific attachment's service:
# config/prod.exs
config :my_app, MyApp.Post,
storage: [
has_one_attached: [
avatar: [service: {AshStorage.Service.S3, bucket: "prod-avatars"}]
]
]Resolution order (first match wins):
- Per-attachment app config
- Per-attachment DSL
serviceoption - Resource-level app config
- Resource-level DSL
serviceoption
AshStorage.Service.Test is an in-memory service for tests. Set it up in your test config:
# config/test.exs
config :my_app, MyApp.Post,
storage: [service: {AshStorage.Service.Test, []}]Then in your test helper or setup:
# test/test_helper.exs
AshStorage.Service.Test.start()
# In each test
setup do
AshStorage.Service.Test.reset!()
:ok
endAshStorage ships with:
AshStorage.Service.Disk— Local filesystem storageAshStorage.Service.Test— In-memory storage for testsAshStorage.Service.S3— S3-compatible storage (requiresreq_s3)
Implement the AshStorage.Service behaviour to add custom backends.
Analyzers✅ — Pluggable metadata extraction (image dimensions, video duration, audio bitrate) stored in blobanalyzersmap. Runs synchronously during attach from local IO by default. With AshOban: optionally enqueue viaanalyze: :oban. Supportswrite_attributesto write results back to parent record attributes. Custom analyzers implement theAshStorage.Analyzerbehaviour.Variants✅ — File transformations: image resizing/conversion, PDF-to-thumbnail, video thumbnails, and any custom transform. Subsumes the previewer concept — a PDF thumbnail is just a variant. Three generation modes::on_demand(default, generated inline on first URL request),:eager(during attach),:oban(background job via AshOban). Variant blobs are self-referential on the blob resource with digest-based cache invalidation. Named variants declared in DSL viavariant :name, {Module, opts}. Custom transformers implementAshStorage.Variantbehaviour.- Per-variant oban jobs — Currently all pending variants for a blob run in a single oban job. Refactor so each variant gets its own job lifecycle, enabling parallel generation and independent retries.
- Checksum verification — Integrity checking via checksums on upload
- Redirect handler — A plug that redirects to the storage service URL instead of proxying
- Mirroring — Mirror service that replicates uploads across multiple backends for redundancy
- Orphan cleanup — Periodic cleanup of blobs without files or files without blobs. With AshOban: scheduled job. Without: manual invocation via
AshStorage.Operations.cleanup_orphans/1.
- GCS — Google Cloud Storage backend
- Azure — Azure Blob Storage backend
These are the Elixir libraries we're evaluating for each roadmap feature. All would be optional dependencies.
| Library | Approach | Notes |
|---|---|---|
image + vix |
libvips NIFs | Recommended. 2-3x faster than ImageMagick, ~5x less memory. Ships pre-built binaries for macOS/Linux. Supports JPEG, PNG, WebP, TIFF, SVG, HEIF, GIF, AVIF. |
mogrify |
ImageMagick shell-out | Legacy option. Well-known but ImageMagick has a much larger CVE surface than libvips. |
| Library | Approach | Notes |
|---|---|---|
ex_image_info |
Pure Elixir | Recommended for lightweight use. Zero deps. Gets dimensions + detected MIME from binary data. Supports JPEG, PNG, GIF, BMP, TIFF, WebP, PSD, SVG, ICO. |
exexif |
Pure Elixir | EXIF/TIFF metadata from JPEGs (camera info, GPS, exposure). |
image |
libvips | Also extracts dimensions and metadata. Good if already using it for variants. |
| Library | Approach | Notes |
|---|---|---|
ffmpex |
FFmpeg shell-out | Recommended. Wraps ffprobe for metadata (duration, bitrate, codecs, dimensions) and ffmpeg for thumbnail extraction. Stable, well-understood. |
xav |
FFmpeg NIFs | NIF-based, no shell-out. Part of elixir-webrtc org, actively maintained. Tighter integration but heavier dependency. |
thumbnex |
ImageMagick + FFmpeg | Simple API for thumbnails from images, videos, and PDFs. Uses convert for PDFs, ffmpeg for videos. |
| Library | Approach | Notes |
|---|---|---|
image / vix |
libvips + poppler | Can render PDF pages to images if libvips is compiled with poppler/PDFium support. Pre-built binaries may or may not include poppler. |
thumbnex |
ImageMagick shell-out | Uses convert to render first page. Requires ImageMagick with Ghostscript. |
| Library | Approach | Notes |
|---|---|---|
gen_magic |
libmagic NIF | Most accurate — uses the same library behind the Unix file command. Supervised process with pooling. Requires libmagic system dep. |
ex_marcel |
Pure Elixir | Port of Rails' Marcel gem (used by ActiveStorage). Uses Apache Tika signature data. No system deps. |
magic_number |
Pure Elixir | Lightweight magic number matching. Older, less actively maintained. |