# A deep dive into Cloudflare Images

> How Cloudflare Images works: resize, convert and crop images with a URL or a Worker, store uploads, serve private images, and what it costs. Lots of examples.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-05 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/cloudflare-images/

Cloudflare Images does two jobs. It **transforms** images (resize, crop, convert to WebP or AVIF) on the fly, at the edge. And, if you want, it **stores** them too.

You can use one job without the other. That's the key thing to understand before anything else.

Let's go through both, with examples.

## The problem Images solves

Say you have a product photo. It's a 4 MB JPEG, 4000 pixels wide.

You need it as a 200px thumbnail in a list. As a 800px image on the product page. As a 1600px version for Retina screens. In WebP for Chrome, in AVIF where supported, in JPEG as a fallback.

That's one photo and already 9 files. Multiply by every image on your site.

The old way is to generate all those files at build time, or at upload time, and store them all. It works, but it's slow, it wastes storage, and every time you change a size you regenerate everything.

Images flips it. You keep **one original**. You ask for the size and format you want **in the URL**. Cloudflare creates it the first time, caches it, and serves it from the edge after that.

## Two ways to use it

Cloudflare calls them two "integration paths":

1. **Bring your own storage.** Your images stay where they are: your server, an [R2 bucket](https://flaviocopes.com/cloudflare-r2/), an S3 bucket, anywhere with a URL. Cloudflare only transforms them.
2. **Store in Images.** You upload originals to Cloudflare. It stores them and serves them from `imagedelivery.net` (or your own domain).

Path 1 is on the **Free plan**. You get 5,000 unique transformations per month for free.

Path 2 needs the **Paid plan**. Storage costs $5 per 100,000 images stored per month, plus $1 per 100,000 images delivered.

Here's the pricing in one place, because it drives every decision below:

| What you do | What you pay |
|---|---|
| Transform images stored anywhere else | First 5,000 unique transformations free, then $0.50 per 1,000 |
| Store images in Images | $5 per 100,000 images / month |
| Deliver images stored in Images | $1 per 100,000 images delivered / month |

A "unique transformation" is one original plus one set of parameters. `photo.jpg` at `width=200` is one. `photo.jpg` at `width=800` is another. Ask for the same one a million times in a month and you still pay once.

Format doesn't count twice. If `width=100,format=auto/photo.jpg` gets served as AVIF to some browsers and WebP to others, that's still one transformation.

## Path 1: transform images you already have

This is what most people want. Let's start here.

### Enable it on your zone

Transformations are off by default. Turn them on once:

1. In the Cloudflare dashboard, go to **Images** → **Transformations**.
2. Pick the zone (your domain).
3. Enable **transformations**.

That's it. No code deploy, no Worker.

### The URL format

You transform an image by putting `/cdn-cgi/image/` and some options in front of its path:

```text
https://yourdomain.com/cdn-cgi/image/<options>/<source-image>
```

Say you have `https://shop.example/photos/chair.jpg`. To get it 400 pixels wide:

```text
https://shop.example/cdn-cgi/image/width=400/photos/chair.jpg
```

The source can be a path on the same domain (like above) or a full URL:

```text
https://shop.example/cdn-cgi/image/width=400/https://shop.example/photos/chair.jpg
```

Options are comma-separated. Here's a width, a height, and a fit mode together:

```text
/cdn-cgi/image/width=400,height=300,fit=cover/photos/chair.jpg
```

You need at least one option. `/cdn-cgi/image//photos/chair.jpg` with nothing in between is an error.

### The options you'll use most

**Resize:**

```text
/cdn-cgi/image/width=800/photos/chair.jpg
/cdn-cgi/image/height=600/photos/chair.jpg
/cdn-cgi/image/width=800,height=600/photos/chair.jpg
```

**Fit** decides what happens when the aspect ratio doesn't match:

```text
/cdn-cgi/image/width=300,height=300,fit=scale-down/photos/chair.jpg
/cdn-cgi/image/width=300,height=300,fit=contain/photos/chair.jpg
/cdn-cgi/image/width=300,height=300,fit=cover/photos/chair.jpg
/cdn-cgi/image/width=300,height=300,fit=crop/photos/chair.jpg
/cdn-cgi/image/width=300,height=300,fit=pad/photos/chair.jpg
```

- `scale-down` shrinks to fit, never enlarges
- `contain` fits inside the box, keeps the ratio, can enlarge
- `cover` fills the box completely and crops the extra
- `crop` is `cover` for big images and `scale-down` for small ones
- `pad` fits inside and fills the rest with a background color

For square thumbnails from photos of any shape, `fit=cover` is the one you want.

**Format.** This is where the biggest savings are:

```text
/cdn-cgi/image/format=auto/photos/chair.jpg
/cdn-cgi/image/format=webp/photos/chair.jpg
/cdn-cgi/image/format=avif/photos/chair.jpg
```

`format=auto` looks at the browser's `Accept` header and picks the best format it supports. AVIF where possible, WebP otherwise, JPEG or PNG as a fallback. Use it everywhere unless you have a reason not to.

**Quality**, from 1 to 100. The default is 85:

```text
/cdn-cgi/image/quality=75/photos/chair.jpg
```

You can also use words: `quality=high`, `medium-high`, `medium-low`, `low`.

**Device pixel ratio**, for Retina screens. This serves a 400px image at 2x, so 800 real pixels:

```text
/cdn-cgi/image/width=400,dpr=2/photos/chair.jpg
```

**Effects:**

```text
/cdn-cgi/image/blur=20/photos/chair.jpg
/cdn-cgi/image/sharpen=2/photos/chair.jpg
/cdn-cgi/image/rotate=90/photos/chair.jpg
/cdn-cgi/image/brightness=1.2,contrast=1.1/photos/chair.jpg
```

**Strip metadata.** JPEGs from a camera carry EXIF data, sometimes including GPS coordinates. This removes it:

```text
/cdn-cgi/image/metadata=none/photos/chair.jpg
```

A realistic thumbnail URL combines a few of these:

```text
/cdn-cgi/image/width=300,height=300,fit=cover,format=auto,quality=80,metadata=none/photos/chair.jpg
```

There are more options: `gravity` to choose which part to keep when cropping (including `gravity=auto` for face and subject detection), `trim` to cut borders, `background` for `fit=pad`, `anim=false` to freeze a GIF. The [full list is in the docs](https://developers.cloudflare.com/images/optimization/features/).

### Responsive images with srcset

Because every size is just a URL, `srcset` becomes trivial. No build step, no pre-generated files:

```html
<img
  src="/cdn-cgi/image/width=800,format=auto/photos/chair.jpg"
  srcset="
    /cdn-cgi/image/width=400,format=auto/photos/chair.jpg 400w,
    /cdn-cgi/image/width=800,format=auto/photos/chair.jpg 800w,
    /cdn-cgi/image/width=1600,format=auto/photos/chair.jpg 1600w
  "
  sizes="(max-width: 800px) 100vw, 800px"
  alt="Oak dining chair"
/>
```

The browser picks the size it needs. Cloudflare generates it the first time someone asks.

Remember the billing: three widths means three unique transformations per image. That's how the 5,000 free ones get used up.

### Let Cloudflare pick the width

If you can't change your HTML, `width=auto` does the choosing server-side:

```text
/cdn-cgi/image/width=auto,format=auto/photos/chair.jpg
```

Cloudflare reads client hints from the browser (when your page sends them) or falls back to user-agent detection. It snaps to a breakpoint: 320, 768, 960, or 1200 pixels by default.

To enable client hints, add this to your `<head>`:

```html
<meta http-equiv="Delegate-CH" content="sec-ch-dpr shop.example; sec-ch-viewport-width shop.example" />
```

### Images from another domain

By default Cloudflare only fetches source images from the same zone. If your originals live on an R2 bucket with a custom domain, or on a different site, you need to allow that origin.

In the dashboard, under **Images** → **Transformations** → your zone → **Sources**, add the domain. Then this works:

```text
https://shop.example/cdn-cgi/image/width=400/https://files.shop.example/chair.jpg
```

This is the combination I find most interesting: **R2 for storage, Images for transformation**. R2 has no egress fees. Images has 5,000 free transformations. Together they're a full image pipeline for a few cents a month.

### What happens when it fails

If the source is too big (over 100 MB, or over 12,000 pixels on a side), or it's not an image, the transformation fails.

Add `onerror=redirect` and Cloudflare redirects the browser to the original instead of showing an error:

```text
/cdn-cgi/image/width=400,onerror=redirect/photos/chair.jpg
```

This only works when the source is on the same zone. It's also what saves you on the Free plan: once you pass 5,000 unique transformations, new ones fail with error `9422`, and `onerror=redirect` falls back to the original. Cached transformations keep working.

### Hide the /cdn-cgi/image/ prefix

The URLs are ugly. You can hide them with a Transform Rule that rewrites `/img/*` to `/cdn-cgi/image/...`. Or use **transformation flows**, which apply options automatically based on the path or file extension, so your existing `<img src="https://flaviocopes.com/photos/chair.jpg">` gets optimized without touching the markup.

Both are configured in the dashboard, under **Images** → **Transformations**. Flows are the simplest way to optimize an existing site with zero code changes.

## Path 1, from a Worker

The URL approach is great until you need logic. Check if the user is logged in. Watermark only some images. Pick a format based on your own rules.

That's when you move to a [Worker](https://flaviocopes.com/cloudflare-workers/). There are two APIs.

### fetch() with cf.image

The first is the oldest. You call `fetch()` on the original and pass image options in the `cf` object:

```js
export default {
  async fetch(request) {
    const url = new URL(request.url)
    const width = parseInt(url.searchParams.get('width') || '800', 10)

    const options = {
      cf: {
        image: { width, fit: 'scale-down', quality: 80 },
      },
    }

    const accept = request.headers.get('Accept') || ''
    if (accept.includes('image/avif')) {
      options.cf.image.format = 'avif'
    } else if (accept.includes('image/webp')) {
      options.cf.image.format = 'webp'
    }

    return fetch('https://shop.example/photos/chair.jpg', options)
  },
}
```

Now `https://shop.example/thumb?width=300` returns the resized image.

Notice the `Accept` header check. With `fetch()` there's no `format=auto`. Your Worker does the negotiation.

Also notice the response is cached by Cloudflare like any fetched image. You don't handle caching yourself.

### The Images binding

The second API is newer and more flexible. It's a **binding**, like KV or R2, so it doesn't need the image to have a URL at all. You can pass it bytes from an upload, from R2, from anywhere.

Add it to `wrangler.jsonc`:

```jsonc
{
  "images": {
    "binding": "IMAGES"
  }
}
```

Now `env.IMAGES` is available. The pattern is always: `input()` → `transform()` → `output()` → `response()`.

```js
export default {
  async fetch(request, env) {
    const original = await fetch('https://shop.example/photos/chair.jpg')

    const result = await env.IMAGES.input(original.body)
      .transform({ width: 400 })
      .output({ format: 'image/webp' })

    return result.response()
  },
}
```

`input()` takes a stream or an `ArrayBuffer`, up to 20 MB. `output()` needs a format, there's no default. `response()` gives you a `Response` with the right `Content-Type`.

You can chain transforms. They apply in order, which you can't control with the URL API:

```js
await env.IMAGES.input(stream)
  .transform({ rotate: 90 })
  .transform({ width: 400, height: 400, fit: 'cover' })
  .transform({ blur: 10 })
  .output({ format: 'image/avif' })
```

### Cache the binding output

This is the one thing that bites people. **The binding does not cache.** Every call decodes and re-encodes the image. If you serve it on a hot path without caching, you pay latency on every request.

Turn on the Workers cache in `wrangler.jsonc`:

```jsonc
{
  "cache": {
    "enabled": true
  }
}
```

And set `Cache-Control` on the response:

```js
return (
  await env.IMAGES.input(stream)
    .transform({ width: 800 })
    .output({ format: 'image/webp' })
).response({
  headers: {
    'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
  },
})
```

Repeat requests are then served from cache without running your Worker. Billing helps here too: since July 2026, the binding counts unique transformations like the URL API does, so repeat calls for the same image and parameters within a month aren't billed again.

I wrote about the [Workers Cache API](https://flaviocopes.com/cloudflare-workers-cache/) if you want the details on how that layer works.

### R2 + Images binding

Here's the full "serve a resized image from R2" Worker. This is the pattern I'd reach for first when building an app with uploads:

```js
export default {
  async fetch(request, env) {
    const url = new URL(request.url)
    const key = url.pathname.slice(1)
    const width = parseInt(url.searchParams.get('w') || '800', 10)

    const object = await env.UPLOADS.get(key)
    if (!object) {
      return new Response('Not found', { status: 404 })
    }

    return (
      await env.IMAGES.input(object.body)
        .transform({ width, fit: 'scale-down' })
        .output({ format: 'image/webp' })
    ).response({
      headers: { 'Cache-Control': 'public, max-age=86400' },
    })
  },
}
```

`env.UPLOADS` is an R2 binding. Request `/avatars/user-123.png?w=200` and you get a 200px WebP.

### Watermarks and overlays

`draw()` puts one image on top of another:

```js
const photo = await fetch('https://shop.example/photos/chair.jpg')
const logo = await fetch('https://shop.example/logo.png')

const result = await env.IMAGES.input(photo.body)
  .draw(env.IMAGES.input(logo.body).transform({ width: 120 }), {
    bottom: 20,
    right: 20,
    opacity: 0.6,
  })
  .output({ format: 'image/jpeg' })

return result.response()
```

The overlay can itself be transformed (here, resized to 120px). Position with `top`, `bottom`, `left`, `right`. `opacity` makes it translucent. `repeat: true` tiles it across the image.

You can chain multiple `draw()` calls for multiple overlays.

### Text on images

Since September 2026 the binding can rasterize text. This is for things like generated social cards or "SOLD" badges:

```js
const result = await env.IMAGES.input(photo.body)
  .draw(
    env.IMAGES.text('SOLD OUT', {
      font: { url: 'https://shop.example/fonts/Inter-Bold.otf' },
      color: '#ffffff',
      size: 72,
    }),
    { top: 40, left: 40 }
  )
  .output({ format: 'image/png' })
```

You provide the font file. The text is drawn on the base image's canvas.

### Read image info

`info()` tells you what an image is without transforming it. Handy for validating uploads:

```js
const info = await env.IMAGES.info(stream)
// { format: 'image/jpeg', fileSize: 412903, width: 4000, height: 3000 }

if (info.width > 8000) {
  return new Response('Image too large', { status: 400 })
}
```

`info()` calls are free.

## Path 2: store images in Images

Now the second job. You upload originals to Cloudflare and it hosts them.

Why would you, if R2 exists? Three reasons:

- **Variants.** You define named sizes once (`thumbnail`, `hero`) and every image gets them automatically.
- **Direct Creator Upload.** Users upload straight to Cloudflare from the browser, without your server touching the file and without exposing an API token.
- **Signed URLs** for private images, built in.

You need the Paid plan for this.

### Upload via API

The simplest upload is a `curl`:

```bash
curl --request POST \
  https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
  --header "Authorization: Bearer <API_TOKEN>" \
  --form 'file=@./chair.jpg'
```

The response includes the image ID and the delivery URLs:

```json
{
  "result": {
    "id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
    "filename": "chair.jpg",
    "uploaded": "2026-09-03T10:12:44.000Z",
    "requireSignedURLs": false,
    "variants": [
      "https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/public"
    ]
  },
  "success": true
}
```

You can also upload from a URL instead of a file:

```bash
curl --request POST \
  https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
  --header "Authorization: Bearer <API_TOKEN>" \
  --form 'url=https://shop.example/photos/chair.jpg'
```

### Upload from a Worker

The Images binding has a `hosted` namespace for storage. No API token needed, the binding is already authenticated:

```js
export default {
  async fetch(request, env) {
    const image = await env.IMAGES.hosted.upload(request.body, {
      filename: 'chair.jpg',
      metadata: { productId: 'chair-oak-01' },
    })

    return Response.json(image)
  },
}
```

`POST` a file body to this Worker and it lands in Images. The response has the `id` and `variants`.

The same namespace lists, reads, and deletes:

```js
const { images, cursor } = await env.IMAGES.hosted.list({ limit: 50 })

const bytes = await env.IMAGES.hosted.image(id).bytes()

await env.IMAGES.hosted.image(id).delete()
```

`list()` is paginated. Pass the `cursor` you got back to fetch the next page. You can also filter by the metadata you set at upload time, which is how you'd find all the images of one user.

One nice thing: when you run `wrangler dev`, hosted-image operations hit a local mock backed by an embedded KV namespace. You can develop the whole upload flow offline.

### The delivery URL

Every hosted image is served from:

```text
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT>
```

The account hash is in the dashboard under **Images** → **Developer Resources**. The default variant is `public`.

```text
https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/public
```

You can serve from your own domain instead. Any domain on your Cloudflare account works, using the path `/cdn-cgi/imagedelivery/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT>`.

### Variants

A **variant** is a named set of options. Create them in the dashboard (**Hosted images** → **Delivery** → **Create variant**) or via the API:

```bash
curl --request POST \
  https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1/variants \
  --header "Authorization: Bearer <API_TOKEN>" \
  --header "Content-Type: application/json" \
  --data '{
    "id": "thumbnail",
    "options": { "fit": "cover", "width": 300, "height": 300, "metadata": "none" }
  }'
```

Now every image, past and future, has a `thumbnail` version:

```text
https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/<IMAGE_ID>/thumbnail
```

You can have up to 100 variants. Defining a variant doesn't count as storage, only originals do.

I like variants because they keep the size decisions in one place. Change `thumbnail` from 300 to 320 pixels and every thumbnail on the site updates.

### Flexible variants

Variants are fixed. If you want to pass options in the URL like Path 1, enable **flexible variants** (**Hosted images** → **Delivery** → **Flexible variants**):

```text
https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/<IMAGE_ID>/w=400,sharpen=3
```

Now it works like `/cdn-cgi/image/`, with the same options. One catch: flexible variants don't work on private images that need signed URLs.

### Custom IDs

By default images get a UUID. You can set your own ID, including slashes, so the URL means something:

```bash
curl --request POST \
  https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
  --header "Authorization: Bearer <API_TOKEN>" \
  --form 'file=@./chair.jpg' \
  --form 'id=products/chair-oak-01/main'
```

```text
https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/products/chair-oak-01/main/public
```

Images with custom IDs can't be made private with signed URLs. Pick one or the other.

### Direct Creator Upload

This is the feature that makes the Paid plan worth it for apps with user uploads.

The flow is:

1. Your backend asks Cloudflare for a **one-time upload URL**.
2. You give that URL to the browser.
3. The browser uploads the file straight to Cloudflare.

Your server never sees the file. Your API token never reaches the browser.

From a Worker:

```js
export default {
  async fetch(request, env) {
    const { id, uploadURL } = await env.IMAGES.hosted.createDirectUpload({
      metadata: { userId: 'u_8f2a' },
      expiresIn: 600,
    })

    return Response.json({ id, uploadURL })
  },
}
```

Or with `curl` from any backend:

```bash
curl --request POST \
  https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v2/direct_upload \
  --header "Authorization: Bearer <API_TOKEN>" \
  --form 'metadata={"userId":"u_8f2a"}'
```

Either way you get back something like:

```json
{
  "id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
  "uploadURL": "https://upload.imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901"
}
```

The browser then posts the file to `uploadURL`:

```js
const { id, uploadURL } = await fetch('/api/upload-url').then((r) => r.json())

const form = new FormData()
form.append('file', fileInput.files[0])

await fetch(uploadURL, { method: 'POST', body: form })

// the image is now at
// https://imagedelivery.net/<ACCOUNT_HASH>/<id>/public
```

Store the `id` in your database next to the user. That's your whole upload pipeline.

The upload URL expires after 30 minutes by default. `expiresIn` accepts anything from 2 minutes to 6 hours. Until someone uploads, the image is a draft and doesn't appear in your list.

If you want to know when the upload finished without polling, Images supports webhooks.

You'll probably want to protect the endpoint that hands out upload URLs, or bots will fill your storage. [Turnstile](https://flaviocopes.com/cloudflare-turnstile/) is the natural fit here.

### Private images with signed URLs

Mark an image as private with `requireSignedURLs: true` at upload time:

```js
const image = await env.IMAGES.hosted.upload(request.body, {
  requireSignedURLs: true,
})
```

Now the plain delivery URL returns an error. To let someone see it, generate a URL with an expiry and a signature:

```js
const url = await env.IMAGES.hosted.image(id).signedUrl({
  variant: 'public',
  expiresIn: 3600,
})

return Response.redirect(url, 302)
```

The binding signs it for you, so your Worker never touches the signing key. The URL looks like:

```text
https://imagedelivery.net/<HASH>/<ID>/public?exp=1756900000&sig=3a9f...
```

If you're not on Workers, you can sign manually. It's an HMAC-SHA256 of the path and the `exp` query string, using the key from **Hosted images** → **Keys**. I explained the same technique in [HMAC signed URLs with Cloudflare Workers](https://flaviocopes.com/hmac-signed-urls-cloudflare-workers/).

One escape hatch: a variant can be marked **Always allow public access**. Then that variant works without a signature even on private images. Useful for a blurred `preview` variant of a paid image.

### Limits

Hosted images have tighter limits than remote ones:

| | Remote (Path 1) | Hosted (Path 2) |
|---|---|---|
| File size | 100 MB | 10 MB |
| Area | 100 megapixels | 100 megapixels |
| Longest side | 12,000 px | 12,000 px |
| Metadata | | 1,024 bytes |

Input formats: PNG, JPEG, GIF, WebP, SVG, HEIC (AVIF input is Enterprise only). Output: PNG, JPEG, GIF, WebP, SVG, AVIF.

## A note on SVG

Images never resizes SVGs. They're vectors, there's nothing to resize. Any size option is ignored.

What it does do is **sanitize** them. SVGs are XML and can contain scripts and external links. Cloudflare runs them through [svg-hush](https://github.com/cloudflare/svg-hush), which strips scripting, hyperlinks, and cross-origin references.

So if you accept SVG uploads, serving them through Images is a cheap safety layer.

## R2 + transformations or Images storage?

This is the decision most people face. Here's how I think about it.

**Use R2 + transformations when:**

- you already have files in R2 or S3
- you want the cheapest possible setup (R2 has 10 GB free, Images has 5,000 free transformations)
- your images are big (R2 has no 10 MB limit)
- you need the files for other things too, like backups or processing

**Use Images storage when:**

- users upload images and you want Direct Creator Upload
- you want variants managed for you
- you need signed URLs without writing the signing code
- you'd rather have one product than two

Storage cost is where they differ most. R2 bills per GB. Images bills per image, regardless of size. 100,000 small avatars in Images is $5 a month. 100,000 5 MB photos in Images is also $5 a month, while in R2 that's 500 GB, around $7.50. So Images wins on big files and loses on tiny ones, as long as you also account for the $1 per 100,000 delivered.

## How I would use it

I don't use Cloudflare Images today, so let me be precise about what I would do and where.

This site is a static Astro build on Cloudflare Pages. The images for the blog posts live in the repository, in `public/images/<slug>/`. There are 721 of those folders and together they weigh 387 MB. I optimize them by hand before committing, and I generate the OG social cards with `sharp` at build time.

Path 1 would fit this site with zero code changes. The domain is already on Cloudflare. I would enable transformations on the zone, then set up a flow that applies `format=auto,quality=80` to everything under `/images/`. Every reader on a modern browser would get AVIF or WebP, and the repo would keep the originals it has now.

The catch is the free tier. I have a lot of images, and each one is a unique transformation. If I also added `width` variants for `srcset`, I would pass 5,000 quickly. With `onerror=redirect` that just means the extra images fall back to the original, so nothing breaks. But I would want to either accept the Paid plan or limit the flow to the posts that get traffic.

For the downloads bucket, which is R2 behind `downloads.flaviocopes.com`, Images is irrelevant. Those are PDFs and EPUBs.

Where I would use Path 2 is anything with user uploads. Take the bootcamp: if I added a page where students post a screenshot of what they built each week, I would use Direct Creator Upload behind Turnstile, a `thumbnail` variant for the gallery, and `requireSignedURLs` for anything students want to keep inside the cohort. The whole thing is maybe 40 lines of Worker code, and I never store an image on my own infrastructure.

I would not use Images for the OG cards. Those are generated once at build time from text, and `sharp` already does it. Rendering them at request time would be slower and cost transformations for no gain.

## When Images is a poor fit

- **Video.** Images doesn't do video. Cloudflare Stream does, and there's a separate `/cdn-cgi/media/` for short clips.
- **Heavy editing.** You get resize, crop, rotate, blur, sharpen, overlays, and text. You don't get filters, curves, or content-aware anything beyond `gravity=auto` and AI `upscale`.
- **Non-Cloudflare sites.** Path 1 needs your domain on a Cloudflare zone. If your site is elsewhere, you can still use Path 2 (upload to Images, serve from `imagedelivery.net`), but you lose the "transform what you already have" story.
- **Files over 10 MB you want to host.** Use R2 and transform from there.
- **SVG resizing.** It won't. Handle SVGs in your build.

## Wrapping up

Cloudflare Images is two products with one name. The transformation half is free for small sites, needs no code, and turns one original into every size and format you need. The storage half costs a little, and pays for itself the moment users start uploading.

My advice: start with Path 1 on whatever storage you have. Add the Paid plan only when you need Direct Creator Upload or signed URLs.

The [Cloudflare Images docs](https://developers.cloudflare.com/images/) cover every option in detail. If you want to see how Images fits with the rest of the platform (Workers, R2, KV, Pages), I put together a [free Cloudflare course](https://flaviocopes.com/courses/cloudflare/) that goes through all of it.
