Serving images through a CDN

Copy Markdown View Source

Set up ImagePipe so a CDN caches processed images at the edge and revalidates them with ImagePipe when they go stale. This guide assumes ImagePipe is already running in your app (see Getting started with Phoenix) or as image_pipe_server, and that you can edit your CDN's cache settings. Why images get the lifetimes they do is covered in Caching and freshness.

Turn on cache headers

By default ImagePipe sends an ETag but no cache lifetime for images from immutable sources. Set http_cache to auto so responses carry a Cache-Control the CDN can follow:

Plug

# lib/my_app_web/router.ex
forward "/images", ImagePipe.Plug,
  http_cache: :auto,
  sources: [
    media: [
      adapter: ImagePipe.Source.File,
      match: :path,
      options: [root: "/srv/images", root_id: "media"]
    ]
  ]

image_pipe_server

[http]
http_cache = "auto"

The header modes table lists the other values.

Set source lifetimes

What the CDN may cache, and for how long, comes from each source:

  • A source whose files never change gets a one-year lifetime once you mark it immutable (see immutable sources).
  • HTTP and S3 sources pass on the lifetime their origin sends. S3 objects addressed by version count as immutable.
  • Sources that can change and get no lifetime from their origin, including local files, get max-age=0, so the CDN revalidates every request. Set a fallback lifetime to let the CDN reuse them for a while.

Mark an immutable source like this:

Plug

options: [root: "/srv/images", root_id: "media", stable: :immutable]

image_pipe_server

[sources.media]
adapter = "file"
match = "path"
root = "/srv/images"
root_id = "media"
stable = "immutable"

Or give a source whose files can change a one-hour fallback lifetime:

Plug

options: [
  root: "/srv/images",
  root_id: "media",
  cache_policy: [freshness: {:fallback, 3600}]
]

image_pipe_server

[sources.media]
adapter = "file"
match = "path"
root = "/srv/images"
root_id = "media"
cache_policy = { freshness = { fallback = 3600 } }

A source can't have both. A changed file keeps being served from the CDN until its lifetime runs out.

Keep per-user images private

If a source serves images that differ per user, such as private uploads, set http_cache to private on that source. Its responses get Cache-Control: private, which a CDN doesn't store, while other sources stay public:

Plug

uploads: [
  adapter: ImagePipe.Source.File,
  match: [prefix: "uploads"],
  options: [root: "/srv/uploads", root_id: "uploads", http_cache: :private]
]

image_pipe_server

[sources.uploads]
adapter = "file"
match = { prefix = "uploads" }
root = "/srv/uploads"
root_id = "uploads"
http_cache = "private"

The storage_inputs setting lists request headers and cookies that select a different cached image (see the Plug option or the server key). If it names a cookie, auto makes every response private, because Vary can't name cookies.

Configure the CDN

Apply these settings to the CDN route that serves your images:

  • Use ImagePipe's Cache-Control. Don't replace it with an edge lifetime, or the CDN will cache no-store and private responses. A URL signed with an expiry gets a lifetime that ends when the URL does, and an edge lifetime would outlast it.
  • Add Accept to the cache key. URLs without a format option can get WebP or AVIF chosen from the browser's Accept header, and then carry Vary: Accept. Look for the CDN setting that adds request headers to the cache key. If you'd rather not, put a format in every URL. Those responses don't vary.
  • Add your storage_inputs headers to the cache key. Each header named there is listed in Vary too.
  • Keep other headers out of the key. ImagePipe doesn't read Client Hints such as Width or DPR, so they only split the cache.
  • Forward If-None-Match. ImagePipe answers a matching request with 304 Not Modified without processing the image again.

The CDN keys on the raw URL, so two spellings of the same options are two cached copies. Generate URLs with the URL builder, ImagePipe.URL, to keep one spelling per image.

If [server] sets auth_token, configure the CDN to send Authorization: Bearer <token> on its requests to the server.

Check the headers

Request an image from ImagePipe directly. If your URLs are signed, use a signed one.

Plug

$ curl -sI http://localhost:4000/images/w=400/src/photos/beach.jpg

image_pipe_server

$ curl -sI http://localhost:8080/w=400/src/photos/beach.jpg

If [server] sets auth_token, add -H "Authorization: Bearer <token>" to each request.

For an immutable source the response includes:

cache-control: public, max-age=31536000, immutable
etag: "ipr1-..."
vary: Accept

Send the ETag back in If-None-Match:

Plug

$ curl -sI -H 'If-None-Match: "ipr1-..."' http://localhost:4000/images/w=400/src/photos/beach.jpg
HTTP/1.1 304 Not Modified

image_pipe_server

$ curl -sI -H 'If-None-Match: "ipr1-..."' http://localhost:8080/w=400/src/photos/beach.jpg
HTTP/1.1 304 Not Modified

Then request the same URL through the CDN twice. The second response should carry the same cache-control and etag, and the CDN's own hit header, such as x-cache.

A cache-control: no-store response has no ETag and is never cached. Either storage is forbidden for that source, by its origin or by your configuration (see storage permission), or the crop fell back to a default because content detection failed.

Next steps