<.image> and <.picture> produce URLs in the documented URL grammar of the CDN you target. In production you typically point them straight at the real Cloudflare Images / Cloudinary / imgix / ImageKit edge — no Elixir-side processing, no proxy server. The components are doing nothing more than building a src= string the CDN understands.
For development and test, that direct-to-CDN mode is often inconvenient: you don't want every preview render to hit the production account, you don't want to upload local fixture images to a remote bucket, and you don't want to depend on the network. The recommended workaround is to run an in-process image_plug server in dev and test only — it speaks the same URL grammars, so the component templates don't change. This guide shows the recipe.
The two knobs that change
The host= and provider= attributes are the only things that need to vary between environments. Everything else — the per-transform attributes (width, fit, gravity, …), the source path, the rendered HTML — stays identical. The point of the canonical IR is that the same component call works against any of the four CDNs.
<%!-- Same call, different host. --%>
<.image src="/uploads/cat.jpg" provider={:cloudflare} host="/img" width={600} />
<.image src="/uploads/cat.jpg" provider={:cloudflare} host="https://images.example.com" width={600} />The first form points at an in-process image_plug mounted at /img on the same Phoenix endpoint. The second points at Cloudflare Images at images.example.com. Both URLs resolve to the same transformed image.
The recipe
1. Configure the CDN per environment
In config/config.exs, declare a single :image_cdn key with whatever default is most useful for your dev workflow:
# config/config.exs
import Config
config :my_app, :image_cdn,
provider: :cloudflare,
host: "" # in-process image_plug mounted at the app rootThen override in the environment-specific config files where the value differs:
# config/dev.exs
config :my_app, :image_cdn,
provider: :cloudflare,
host: "/img" # in-process image_plug at /img on the dev endpoint
# config/test.exs
config :my_app, :image_cdn,
provider: :cloudflare,
host: "/img" # same in-process mount; tests don't hit the network
# config/runtime.exs (production)
config :my_app, :image_cdn,
provider: String.to_existing_atom(System.fetch_env!("IMAGE_PROVIDER")),
host: System.fetch_env!("IMAGE_HOST")Pulling the prod values from env vars (rather than baking them into a compiled config) means the same release artifact can be deployed against staging and production with different CDNs.
2. Mount image_plug only in dev and test
The in-process server is a development convenience; in production you usually want the real CDN to do the work, so the route doesn't need to exist:
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# ... pipelines, scopes, lives, etc.
if Application.compile_env(:my_app, :image_plug_local?, false) do
forward "/img", Image.Plug,
provider: {Image.Plug.Provider.Cloudflare, []},
source_resolver:
{Image.Plug.SourceResolver.File, root: Path.expand("priv/static/uploads")}
end
endPlus the matching compile-env flag:
# config/dev.exs and config/test.exs
config :my_app, :image_plug_local?, true
# config/prod.exs (or just don't set it)
config :my_app, :image_plug_local?, falseApplication.compile_env/3 is evaluated at compile time, so the forward route is conditionally included in the compiled router — no runtime cost in prod, no risk of accidentally serving image transforms from the production node.
For a deeper look at source resolution — including the directory layout, the Composite dispatcher for serving file/HTTP/hosted sources from one mount, and how to add an S3 resolver — see image_plug's sources guide.
3. Wrap <.image> with your app's defaults
Reading the CDN config in every render is noisy. Wrap the component once:
defmodule MyAppWeb.Components.Image do
use Phoenix.Component
import Image.Components, only: [image: 1, picture: 1]
attr :src, :string, required: true
attr :rest, :global,
include: ~w(width height fit gravity dpr face_zoom format quality
blur sharpen brightness contrast saturation gamma
vignette tint alt class srcset sizes loading decoding)
def img(assigns) do
cdn = Application.get_env(:my_app, :image_cdn, [])
assigns = assign(assigns, provider: cdn[:provider], host: cdn[:host] || "")
~H"""
<.image src={@src} provider={@provider} host={@host} {@rest} />
"""
end
attr :src, :string, required: true
attr :formats, :list, default: [:avif, :webp]
attr :rest, :global,
include: ~w(width height fit gravity dpr face_zoom format quality
blur sharpen brightness contrast saturation gamma
vignette tint alt class loading decoding)
def pic(assigns) do
cdn = Application.get_env(:my_app, :image_cdn, [])
assigns = assign(assigns, provider: cdn[:provider], host: cdn[:host] || "")
~H"""
<.picture src={@src} provider={@provider} host={@host} formats={@formats} {@rest} />
"""
end
endThen use it everywhere:
<.img src="/uploads/cat.jpg" width={600} fit={:cover} alt="A cat" />
<.pic src="/uploads/cat.jpg" formats={[:avif, :webp]} width={1200} />The CDN just changes per environment; the templates stay clean.
Switching provider per environment
Sometimes you want different providers in different environments — for example :cloudflare in production (because that's where you host) but :imgix for local dev (because the dev source lives on an imgix-fronted bucket and signing is cheaper there). The same recipe handles it:
# config/dev.exs
config :my_app, :image_cdn, provider: :imgix, host: "https://my-source.imgix.net"
# config/runtime.exs
config :my_app, :image_cdn, provider: :cloudflare, host: "https://images.example.com"Note that the URL paths emitted by the four projectors are different — /cdn-cgi/image/... vs /<account>/image/upload/... — so the URLs differ between environments, but the rendered transforms match. If you have absolute URLs hard-coded somewhere (e.g. cached HTML in a CDN), switching providers will invalidate them; switching only the host keeps URL paths stable.
Static binding (the "no app config" alternative)
If you don't want runtime config at all and you're happy to bake the CDN into the compiled module, use Application.compile_env/3 instead of Application.get_env/3 in the wrapper:
defmodule MyAppWeb.Components.Image do
use Phoenix.Component
import Image.Components, only: [image: 1]
@cdn Application.compile_env!(:my_app, :image_cdn)
attr :src, :string, required: true
attr :rest, :global
def img(assigns) do
assigns = assign(assigns, provider: @cdn[:provider], host: @cdn[:host])
~H"""
<.image src={@src} provider={@provider} host={@host} {@rest} />
"""
end
endThis requires a release rebuild to change the CDN, but eliminates the per-render Application.get_env/3 lookup. For most apps the runtime form is simpler and the lookup cost is negligible.
Why use the same provider in dev and prod?
Because the URL grammar then matches. Cloudflare's /cdn-cgi/image/width=600/cat.jpg is the same string whether served by your local image_plug or by the real Cloudflare edge. The browser can cache it, the URL appears in your HTML the same way, and switching environments doesn't shake out subtle URL-shape bugs that only appear in production.
The provider you pick in dev should usually be the provider you pay for in prod, even if they accept very different feature sets — the in-process image_plug faithfully implements each provider's URL grammar, so you'll see the same :contrast=1.4 is dropped on ImageKit (per the gap table) whether you're hitting localhost or the real edge.
Production extras worth knowing
Signed URLs. Cloudflare hosted Images and Cloudinary signed delivery use HMAC signing that the URL projectors don't currently emit. If you need signed URLs in production, build them with the provider's signing helpers (
Image.Plug.Provider.Cloudflare.Signing,Image.Plug.Provider.Cloudinary.Signing) and pass the signed URL as thesrc=attribute (skip the projector's host machinery for those URLs).CDN account in
cloudinary_account/imagekit_endpoint. If your Cloudinary cloud-name or ImageKit endpoint is not"demo", set the per-CDN segment via the component attrs of the same name — or bake them into your wrapper.TLS port in
URL_PORT/URL_SCHEME. Whenimage_plugruns on the same Phoenix endpoint as your app and that endpoint is behind a TLS terminator, set those env vars in your release config (see the runtime.exs ofimage_playgroundfor an example).
Related
image_plug's sources guide — how source resolution actually works, the default file resolver, the HTTP resolver, and how to add a custom one (S3 example).image_plug's usage guide — the full mount story.Image.Components.URL— the four URL builders this guide configures around.