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.

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:

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.

Guides

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

Configuration reference

Image.Plug.init/1 accepts:

Option Default Meaning
: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_000 Soft upper bound on output pixel count.
:request_timeout10_000 Per-request budget in ms.
:telemetry_prefix[:image_plug] Atom list prepended to telemetry event names.

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.