Press Ctrl+K to search anytime
Burger

We Designed a $65-70-a-Month Image Pipeline Without Replacing Cloudinary

otherJuly 21, 2026Dotby Sergey Tarasov
We Designed a $65-70-a-Month Image Pipeline Without Replacing Cloudinary

The wrong way to leave a four-figure Cloudinary bill is to replace Cloudinary.

That was the tempting move, but the account held about 1 TB of originals and other properties still depended on it - moving the whole library is a migration project with a large blast radius. The better question is narrower: how do we stop paying Cloudinary to transform and deliver the same pixels over and over, while keeping it as the place those originals live?

We designed a small image pipeline for a vacation-rental listings marketplace around that question: a self-hosted scaler behind the Cloudflare CDN already in use, with Cloudflare R2 as an originals mirror, leaving Cloudinary as the system of record. Expected recurring cost: about $65-70 per month, down from a four-figure bill.

The interesting part is not putting sharp on a VM - anyone can do that. Two subtler details make the economics hold, and they are the ideas I would carry into any image-pipeline redesign:

  • R2 makes Cloudinary egress a one-time fetch per original, not a recurring cache-miss tax.
  • The compute is sized for memory during cold request packs, not for average transforms per second.

Cloudinary was not the thing to remove

The account was already in better shape than its bill suggested. The app had normalized image URLs to deterministic w, h, q transforms, and Cloudflare sat in front with Tiered Cache and Cache Reserve, so most derivatives lived durably at the edge. What stayed expensive was Cloudinary's per-transformation pricing (about 137k transforms/day, ~4.1M/month) and the bytes pulled from Cloudinary on origin misses. So the design keeps what Cloudinary is good at - durable original storage and the upload workflow - and moves what is expensive at this volume: producing derivatives and serving origin misses.

Cloudflare serves; on a miss the scaler resizes and reads from the R2 mirror, hitting Cloudinary once per original
Cloudflare serves; on a miss the scaler resizes and reads from the R2 mirror, hitting Cloudinary once per original

The traffic path is deliberately boring. A user requests a derivative through Cloudflare; on an edge miss, nginx sends it to an in-house scaler that resizes with sharp/libvips (the same engine family as Cloudinary). Cloudflare still owns the derivative cache, so the VM needs no big disk of generated files - its small nginx cache exists only so proxy_cache_lock collapses a burst of identical misses into one fetch-and-resize instead of a stampede.

The R2 mirror is the whole trick

Flat-rate compute removes metered transformations, but it does not solve origin economics. Without a mirror, every derivative cache miss can still reach Cloudinary - a local cache only helps until the disk fills, the instance is replaced, or a new size or format is requested. That is not a durable bound on egress.

R2 changes the shape of the problem. On a miss the scaler checks R2 first; egress from R2 to the compute host is free. If the original is not there, it fetches from Cloudinary once, writes it to R2, and every later request - any thumbnail, any width, any format - starts from R2. A Cloudinary fetch is tied to the unique original, not to a derivative key. That buys three kinds of resilience: Cache Reserve can evict a derivative without reopening Cloudinary egress, a VM can be rebuilt without losing the originals cache, and a new srcset width can fan out without paying Cloudinary again. After a lazy warm-up - we fill R2 only as traffic asks for an asset, never a bulk copy - Cloudinary egress approaches zero except for genuinely new uploads.

Average CPU would buy the wrong box

Divide the volume by time and it looks calm: ~137k transforms/day is roughly 1.6 per second, and the scaler only sees Cloudflare misses, so its real rate is at or below that. A sister site runs the same scaler at 1-2 cores. A small CPU-focused box looks obvious - and it is the wrong sizing model.

A concurrency cap keeps peak memory bounded no matter how large the cold request pack
A concurrency cap keeps peak memory bounded no matter how large the cold request pack

Image traffic does not arrive as an average. A gallery's srcset or a crawler sweep creates a pack of cold misses at once, each starting a 2-3k px decode that holds ~50-120 MB in libvips. The risk is not a CPU graph at 100%; it is a synchronized pack eating memory faster than a small box can survive. The fix is a concurrency cap - a semaphore and queue that make peak memory independent of burst size. That drives a 16 GB RAM floor (the recommended baseline is two 8 GB nodes for ~$48-50/month, high-availability from day one). The lesson: size for the simultaneous decodes that hurt you, then cap them. Do not buy cores to solve a memory burst.

Add R2 storage for 1 TB of originals ($15/month) and a few dollars of operations, and steady-state Cloudinary and Cloudflare egress near zero, and the recurring total lands at about $65-70/month - fixed compute plus cheap durable storage, instead of a per-request meter.

The Cloudflare-native options we rejected on cost

We first looked at keeping the whole path inside Cloudflare. Both metered options were a bad fit at this volume:

  • Cloudflare Image Transformations (~$650-2,000/mo) - the same per-transform metered model we were trying to leave.
  • Cloudflare Containers (~$586/mo, always on) - built for bursty, scale-to-zero work, not a warm steady resizer.
  • Hetzner block-volume cache (~$60/TB/mo) - an expensive bulk store; R2 keeps the originals at $15/TB with free read-back.

There is also a hard technical wall: sharp cannot run in Cloudflare Workers. It needs native libvips binaries, and Workers are V8 isolates with no native code. WASM resizers exist, but the 128 MB Workers memory limit is a bad match for 2-3k px originals, and per-request billing returns through another door. None of this is anti-Cloudflare - Cloudflare stays the delivery layer and durable derivative cache. It is an argument for putting native image work on flat-cost compute and using the right storage primitive for originals.

Why it was cheap to build

The scaler already exists and runs on a sister site, so this was configuration, integration, and safety work - not a new platform. The estimate was 30-55 hours: adapt the scaler, add the R2 mirror, support legacy URL conversion, enforce the concurrency cap, provision the nodes, cut Cloudflare over, and load-test the request-pack path (the one test that actually matters, because that is where the failure mode lives).

Cloudinary stays in place throughout - no big-bang migration, no new upload system. That is the pattern I would reuse: keep the durable system of record, put transformation work on predictable compute, make the origin mirror durable enough that cache loss cannot bring the old bill back, and cap concurrency before real traffic finds the edge of your RAM.

Aloha, Sergey

Closing Remark

Could your team use some help with topics like this and others covered by ShakaCode's blog and open source? We specialize in optimizing Rails applications, especially those with advanced JavaScript frontends, like React. We can also help you optimize your CI processes with lower costs and faster, more reliable tests. Scraping web data and lowering infrastructure costs are two other areas of specialization. Feel free to reach out to ShakaCode's CEO, Justin Gordon, at justin@shakacode.com or schedule an appointment to discuss how ShakaCode can help your project!
Are you looking for a software development partner who can
develop modern, high-performance web apps and sites?
See what we've doneArrow right
woman programming
hand on board adjusting a fluxogram