Image.Plug

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.

Companion: rendering responsive markup

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.

Why

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.

Installation

Add :image_plug to your dependencies:

def deps do
[
{:image_plug, "~> 0.1"},
{:req, "~> 0.5"} # optional, for the HTTP source resolver
]
end

The :image library is a transitive dependency. Make sure your build has libvips 8.x available.

Quick start

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
end

Then 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.

Image.Plug claims only requests under the provider's :mount (plus, for Cloudflare, /cdn-cgi/image/ URLs at the root). Every other request passes through untouched to the rest of the endpoint pipeline, so it is safe to place plug Image.Plug ahead of plug MyAppWeb.Router — your normal routes are unaffected. Give the provider a :mount prefix so it only intercepts image URLs. See the usage guide for the full routing rules.

Variants

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).

HTTP admin API

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:

MethodPathAction
GET/List all variants.
GET/:nameFetch one variant.
POST/Create a variant. 409 on name conflict.
PUT/:nameUpsert.
PATCH/:namePartial update.
DELETE/:nameDelete.

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.

Guides

For server-rendered components — <.image> and <.picture> — see the companion library image_components.

Configuration reference

Image.Plug.init/1 accepts:

OptionDefaultMeaning
:providerrequired{module, opts} for an Image.Plug.Provider.
:source_resolverrequired{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_pixels25_000_000Soft upper bound on output pixel count.
:request_timeout10_000Per-request budget in ms.
:telemetry_prefix[:image_plug]Atom list prepended to telemetry event names.

The provider's own options (passed in the {module, opts} tuple) include the mount path. Set mount: to the path prefix Image.Plug should serve — for example {Image.Plug.Provider.Cloudflare, mount: "/img"}. When mounted in a Phoenix endpoint ahead of your router, requests outside that prefix pass through to the router untouched; see Mounting in Phoenix.

Error policy

:on_error controls what happens when the pipeline can't produce a result:

Telemetry

The plug emits two events per request under the configured :telemetry_prefix (default [:image_plug]):

AVIF support

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.

Caching

Every successful response carries:

Conditional GET via If-None-Match returns 304 without invoking libvips.

License

Apache-2.0.