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, Cloudinary, imgix, ImageKit, IIIF Image API 3.0) 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.
-
Usage — mounting
Image.Plugin Phoenix orPlug.Router, configuring provider + source resolver + variant store, error policy, telemetry. -
Sources — how source resolution works, the default file resolver, the streaming HTTP resolver, the Composite by-kind dispatcher, and a worked S3-resolver example.
-
Face-aware crops — how
gravity=:faceandface_zoomintegrate with the optional:image_visiondependency, and the URL grammar across the four providers. -
image_plugas a CDN-side service — deploying as the origin behind CloudFront / Fastly / Cloudflare / nginx, tuningCache-Controlfor immutable vs mutable URLs, content-negotiation withVary: Accept, invalidation strategies, and operational concerns. -
Per-CDN conformance: Cloudflare, imgix, Cloudinary, ImageKit, IIIF Image API 3.0 — what each provider's URL grammar parses, with a
✅/⚠️/❌matrix.
For server-rendered components — <.image> and <.picture> — see the companion library image_components.
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.