All posts

Image transformations for an R2 bucket: Cloudflare Images vs Lambda and Sharp vs Dreep

Three ways to resize and convert images kept in your own Cloudflare R2 bucket, with worked costs, setup effort and honest trade-offs.

· 7 min read

You have chosen Cloudflare R2 for your originals, usually because it does not bill egress and speaks the S3 API. Then the product team asks for square thumbnails, WebP and AVIF, a 1200 by 630 social card and a retina hero. R2 stores bytes; it does not resize them. Something has to sit between the bucket and the browser.

This post compares three common answers: Cloudflare's own image transformations, a self-built AWS Lambda function running Sharp, and Dreep connected to your R2 bucket. Prices are as published at the time of writing (October 2026). Check the vendors' pricing pages before you commit, because these numbers change.

What the pipeline actually has to do

Before comparing products, it helps to write down the job. For most apps it is the same list:

  • Produce resized, cropped and re-encoded variants from one original, on request or at upload.
  • Cache each variant so the expensive work happens once, not on every page view.
  • Bound the number of possible variants, so a script requesting width=1, width=2, width=3 cannot run up your bill.
  • Get files into the bucket safely, often straight from a browser, without handing out bucket credentials.
  • Keep some media private or time-limited.
  • Cost something you can predict from your traffic.

Transformation is the visible part. Uploads, access control and abuse limits are where most of the engineering time goes.

Option 1: Cloudflare Images transformations

If your domain is already on Cloudflare, you can enable image transformations on the zone and request resized versions of images served from that zone, including an R2 bucket exposed through a custom domain. Options are passed in the URL or set from a Worker. There is no new vendor and no servers to run.

At the time of writing, Cloudflare bills transformations of remote images per unique transformation: the first 5,000 unique transformations each calendar month are included, then $0.50 per 1,000. A unique transformation is one original with one set of parameters. Requesting the same variant again in the same month does not count twice, but the count resets every month, so a catalogue whose variants keep being viewed is billed for them again each month. Cloudflare states that serving the same image in different formats does not add billable transformations.

Strengths: very little to build if you are already on Cloudflare, and the cost is easy to forecast once you know how many distinct variants are viewed per month. Trade-offs: the price scales with images multiplied by variants, it does nothing for uploads or private media, and it ties your image URLs to a Cloudflare zone.

Option 2: a Lambda function running Sharp

The do-it-yourself route: a function that reads the original from R2 over the S3 API, resizes it with Sharp and returns the result, with a CDN in front so each variant is generated once. A minimal handler looks like this:

JavaScriptresize.mjs (sketch, not production-ready)
// The core of a self-built resizer. Everything around it
// (validation, caching, error pages, metrics) is also yours to write.
import sharp from "sharp";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const r2 = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});

export const handler = async (event) => {
  const { key, width } = parseAndValidate(event); // bound width yourself
  const original = await r2.send(
    new GetObjectCommand({ Bucket: process.env.R2_BUCKET, Key: key }),
  );
  const input = Buffer.from(await original.Body.transformToByteArray());
  const output = await sharp(input).resize({ width }).webp({ quality: 80 }).toBuffer();

  return {
    statusCode: 200,
    headers: { "Content-Type": "image/webp", "Cache-Control": "public, max-age=31536000" },
    body: output.toString("base64"),
    isBase64Encoded: true,
  };
};

Compute is cheap. AWS lists Lambda at $0.20 per million requests and $0.0000166667 per GB-second on x86 in US East at the time of writing, with a monthly free tier of one million requests and 400,000 GB-seconds. A 1,536 MB function that spends 400 ms per resize uses about 0.6 GB-seconds, so 100,000 cache-miss resizes is roughly 60,000 GB-seconds: about $1.00 of compute plus $0.02 of requests before the free tier.

The real cost is everything around that handler:

  • Packaging Sharp's native binaries for the Lambda architecture you deploy to, and keeping them updated.
  • Validating and bucketing parameters so the number of variants stays finite.
  • Configuring the CDN cache key, response size limits and error caching.
  • Building the upload path: presigned PUTs to R2, size and type checks, and a record of what was uploaded.
  • Signed or private delivery, if any of your media is not public.
  • Monitoring, alerting and the on-call time when it breaks.

If you have the time and want total control, this is the lowest marginal cost. If your team is small, the hours usually outweigh the compute bill by a wide margin.

Option 3: Dreep connected to your R2 bucket

Dreep's Bring Your Own Bucket option lets a project store its originals and generated variants in your R2 bucket (or S3, Wasabi, DigitalOcean Spaces, MinIO or another S3-compatible store). You keep paying Cloudflare for the bytes, and they do not count against your Dreep storage quota; Dreep handles uploads, transformations, folder access control and delivery through Dreep CDN URLs. BYOB needs an active Starter or Pro plan.

  1. Your app

    Uploads via API or presigned URL

  2. Dreep

    Validates, stores, transforms

  3. Your R2 bucket

    Originals and variants

  4. Dreep CDN URL

    ?width=640&format=webp

With BYOB, files live in your bucket and are delivered through Dreep URLs.

Uploading and building variants with the JavaScript SDK:

JavaScriptupload.mjs
import { Dreep } from "dreep";
import fs from "node:fs";

const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY });

// The project is connected to your R2 bucket, so the original
// lands in your bucket, not in Dreep storage.
const asset = await dreep.upload({
  file: fs.createReadStream("products/sku-1042.jpg"),
  folder: "products/2026",
});

// Variants are URLs. Nothing is generated until someone asks.
const card = dreep.url(asset, { width: 640, height: 640, fit: "cover", format: "webp" });
const og = dreep.url(asset, {
  width: 1200,
  height: 630,
  fit: "cover",
  gravity: "attention",
  format: "jpeg",
  quality: 85,
});

And the same with the Go SDK:

Gomain.go
client, err := dreep.New(os.Getenv("DREEP_API_KEY"))
if err != nil {
    log.Fatal(err)
}

f, err := os.Open("products/sku-1042.jpg")
if err != nil {
    log.Fatal(err)
}
defer f.Close()

asset, err := client.Upload(ctx, dreep.UploadOptions{
    File:        f,
    Filename:    "sku-1042.jpg",
    Destination: dreep.Destination{Folder: "products/2026"},
})
if err != nil {
    log.Fatal(err)
}

card := client.URL(asset.ID, &dreep.Transform{
    Width: 640, Height: 640, Fit: dreep.FitCover, Format: dreep.FormatWebP,
})

Variants are generated on first request and then served from cache. Widths and heights are rounded up to a fixed set of breakpoints, so the number of variants per image stays bounded however creative the requests get. Browser uploads use a presign, PUT, confirm flow so your API key never reaches the client.

The trade-offs are specific and worth knowing before you start:

  • Connect the bucket before the project holds any files, so every asset lives in one place, and treat the choice as permanent: the bucket cannot be swapped for another one later.
  • Dreep does not transform objects in place. Files already in R2 are brought into the project with the dashboard's R2 import, a one-off background job, or uploaded through the API, and Dreep then manages its own copies of them.
  • Delivery goes through Dreep URLs, so moving away later means rewriting the image URLs you have stored, even though the files are already in your bucket.
  • Monthly transformation quotas are per plan, and when one runs out, transformed requests fail with LIMIT_EXCEEDED until you buy a pack or upgrade. There is no automatic overage billing.

A worked cost example

Assumptions: a marketplace with 20,000 product images, four variants each (thumbnail, card, full width, social card), 2,000 new products a month, and all live variants viewed at least once a month. US prices at the time of writing; taxes and R2 storage, which is the same in every option, are excluded.

Dreep's figure assumes most repeat views are served from cache. A month with heavy cache churn needs more headroom: image transformation packs are $1.99 per 10,000, one-time.
Cloudflare ImagesLambda + SharpDreep (BYOB)
What is billedUnique variants requested per calendar monthRequests and GB-seconds, plus your CDNPlan, plus transformed requests that reach Dreep
Monthly volume88,000 unique variants viewed (80,000 existing + 8,000 new)8,000 new variants, plus cache misses8,000 new variants, plus cache misses
Approximate monthly cost(88,000 − 5,000) ÷ 1,000 × $0.50 = $41.50Under $1 of compute, plus CDN, plus engineering timeStarter $2.99 (10,000 transformations) or Pro $5.99 (50,000)
Uploads and private mediaBuild separatelyBuild separatelyIncluded: presigned uploads, signed folders

Read this table carefully rather than taking the bottom line. Cloudflare's cost grows with the size of the catalogue that is viewed each month, which suits small catalogues and hurts large ones. Lambda's compute is close to free; its cost is the build and the upkeep. Dreep's cost is mostly the plan, with the risk that a burst of uncached variants exhausts the monthly quota.

Which one to pick

  • Pick Cloudflare Images if your site is already on Cloudflare, the catalogue is modest, and you only need public resizing.
  • Pick Lambda and Sharp if you have an engineer who wants to own the pipeline, unusual processing needs, or a strict requirement that every byte stays inside your AWS account.
  • Pick Dreep if you want uploads, transformations, signed delivery and SDKs in one place while the files stay in your own R2 bucket, and you are happy for Dreep to manage the files through its own API and importers.

For the details, see the R2 image transformations page, the Cloudflare R2 integration and the BYOB storage guide in the docs.

Related

Try the examples against your own project

The Free plan includes 1 GB of storage, 100 image and 20 video transformations a month, which is enough to run every snippet in these posts.