A pluggable Plug-based image server for Elixir. Maps URLs to a canonical image-processing pipeline executed via the image library, with named, stored variants. Ships a Cloudflare Images URL provider out of the box.
For Phoenix LiveView apps, the image_components library provides a <.image> (and <.picture>) component that builds best-practice responsive markup against the same Cloudflare URL grammar image_plug parses. The two compose: image_plug serves the bytes; image_components writes the <img srcset sizes> / <picture type media> markup that asks for them.
Pluggable URL grammars mean you can swap your image-CDN's URL syntax (Cloudflare Images, imgix, Imgur, …) without changing the source resolver, the variant store, or the rest of your application. The same canonical pipeline drives every transform.
- Plug-based — mounts under any prefix from a
Plug.Routeror a Phoenix endpoint. - Streaming —
imagedecodes the source progressively (Image.open/2for files;Image.from_req_stream/2for HTTP) and the encoder pipes its output throughPlug.Conn.send_chunked/2+Plug.Conn.chunk/2so libvips never materialises the full encoded body in BEAM memory. - Cloudflare-compatible — recognises both
/cdn-cgi/image/<options>/<source>andimagedelivery.net/<account>/<image-id>/<variant-or-options>URL forms; supports the documented option set includingwidth,height,fit,gravity(named, compass, andXxY),dpr,quality,format(incl.autocontent-negotiation andjsonmetadata),metadata,anim,compression,background,blur,sharpen,brightness,contrast,gamma,saturation,rotate,flip,trim,border,segment,onerror, and adraw=URL grammar for overlays. - Variants — named, stored pipelines that any URL can reference. Provider-neutral: any provider can resolve
/.../<variant-name>against the same store. Includes an HTTP admin plug for variant CRUD. - Cache-aware — strong ETag derived from the source's
etag_seedand the normalised pipeline's fingerprint;If-None-Matchreturns 304 without re-encoding; sensibleCache-Controldefaults;Vary: Acceptforformat=auto. - Soft AVIF fallback — if libvips lacks AVIF write support, requests for
format=avifencode as WebP and the response is tagged withx-image-plug-format-fallback: avif->webp. Detected once at boot. - Friendly error policy — defaults to a placeholder PNG in dev (so broken URLs render visibly in the browser) and to streaming the original source bytes in prod (so a transform bug doesn't break the page).
Add :image_plug to your dependencies:
def deps do
[
{:image_plug, "~> 0.1"},
{:req, "~> 0.5"} # optional, for the HTTP source resolver
]
endThe :image library is a transitive dependency. Make sure your build has libvips 8.x available.
Mount the request plug under your image path and configure a source resolver:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug Image.Plug,
provider: {Image.Plug.Provider.Cloudflare,
mount: "/img",
hosted_account_hash: "abc123"},
source_resolver: {Image.Plug.SourceResolver.Composite,
file: [root: Path.expand("priv/static/uploads")],
http: [allowed_hosts: ["assets.example.com"]]}
plug MyAppWeb.Router
endThen a request to https://example.com/img/cdn-cgi/image/width=600,fit=cover,format=auto/photos/sunset.jpg resolves the source, runs the pipeline, content-negotiates the format (AVIF → WebP → JPEG fallback), and streams the result.
Variants are reusable named pipelines. The hosted URL form /<account>/<image-id>/<variant-name> resolves against the configured Image.Plug.VariantStore.
Define variants at boot:
# config/config.exs
config :image_plug,
variants: [
{"thumbnail", "width=200,height=200,fit=cover,format=webp"},
{"hero", "width=1600,format=auto,quality=82"}
]…or programmatically:
Image.Plug.put_variant("thumbnail", "width=200,height=200,fit=cover,format=webp")
{:ok, variant} = Image.Plug.get_variant("thumbnail")
:ok = Image.Plug.delete_variant("thumbnail")The implicit "public" variant is always seeded and resolves to the empty pipeline (Cloudflare's "no transforms" default).
Mount Image.Plug.Admin under whatever path you protect with auth:
forward "/admin/variants",
to: Image.Plug.Admin,
init_opts: [provider: Image.Plug.Provider.Cloudflare]Routes mirror Cloudflare's variant API:
| Method | Path | Action |
|---|---|---|
GET |
/ |
List all variants. |
GET |
/:name |
Fetch one variant. |
POST |
/ |
Create a variant. 409 on name conflict. |
PUT |
/:name |
Upsert. |
PATCH |
/:name |
Partial update. |
DELETE |
/:name |
Delete. |
Bodies use the canonical JSON shape {"name": ..., "options": ..., "metadata": {...}, "never_require_signed_urls": false}. The plug does not authenticate requests — wrap it in your host's auth pipeline.
Image.Plug.init/1 accepts:
| Option | Default | Meaning |
|---|---|---|
:provider |
required | {module, opts} for an Image.Plug.Provider. |
:source_resolver |
required | {module, opts} for an Image.Plug.SourceResolver. |
:variant_store |
{Image.Plug.VariantStore.ETS, []} |
{module, opts}. |
:on_error |
:auto |
:auto | :render_error_image | :fallback_to_source | :status_text | :raise | {:status, code}. See "Error policy" below. |
:max_pixels |
25_000_000 |
Soft upper bound on output pixel count. |
:request_timeout |
10_000 |
Per-request budget in ms. |
:telemetry_prefix |
[:image_plug] |
Atom list prepended to telemetry event names. |
:on_error controls what happens when the pipeline can't produce a result:
-
:auto(default) — selects:render_error_imagein:dev/:testand:fallback_to_sourcein:prod. The selection key isApplication.get_env(:image_plug, :env, Mix.env())so releases behave correctly. -
:render_error_image— generates a 400×300 PNG placeholder with the error tag and message painted on. Returns 200 so the broken image still renders visibly in browsers.Cache-Control: no-store. -
:fallback_to_source— re-encodes the loaded source image in its source format and streams it. Logs the failure at:error. Returns 200 withx-image-plug-error: <tag>andCache-Control: no-store. Falls through to:status_textif the source itself failed to load. -
:status_text— text/plain body, status code mapped from the error tag (Image.Plug.Error.status/1),x-image-plug-errorheader. -
:raise— propagate the error. -
{:status, code}— use the given status code with a text body.
The plug emits two events per request under the configured :telemetry_prefix (default [:image_plug]):
-
[:image_plug, :request, :start]— at request entry. Measurements:%{system_time}. Metadata:%{request_path, provider}. -
[:image_plug, :request, :stop]— at request completion. Measurements:%{duration}(monotonic native units). Metadata:%{request_path, provider, status, error_tag}. -
[:image_plug, :request, :exception]— only fired if a handler raises. Measurements:%{duration}. Metadata includes%{kind, reason, stacktrace}.
AVIF requires libvips built with libheif plus an AV1 encoder (libaom or librav1e). On builds without those, requests for format=avif are served as WebP with x-image-plug-format-fallback: avif->webp. A warning is logged once at startup. Check at runtime with Image.Plug.Capabilities.avif_write?/0.
Every successful response carries:
- A strong ETag derived from
meta.etag_seedand the normalised pipeline's fingerprint. Two URLs that differ only in option order produce the same ETag. Cache-Control: public, max-age=3600, stale-while-revalidate=86400by default. The source resolver can override viameta.cache_control.Vary: Acceptso the cache differentiates between content-negotiated formats.
Conditional GET via If-None-Match returns 304 without invoking libvips.
Apache-2.0.