How to host blog images for free with Backblaze B2 and Cloudflare

When I migrated this blog to Hugo, I had to figure out where images would live. My original thought was simple enough: store them in the GitHub repo. That worked fine for six months. Then I started thinking about what happens when I have hundreds of posts with images, and the repo turns into a photo dump.

I looked at Cloudflare Images next. It seemed obvious—I'm already paying for Cloudflare for DNS and SSL. But the pricing pushed me away: the transform API costs add up if you're generating variants (WebP, different sizes), and I'd be locking myself into Cloudflare's image manipulation if I ever wanted to migrate. That didn't feel like room to grow.

So I landed on Backblaze B2 + Cloudflare. Eight months in, I'm still surprised at how cheap and invisible this setup is. Here's why it works, and how to replicate it.

Why not Cloudflare Images?

Cloudflare Images charges per image stored (it's not terrible—$5/month for 100,000 images) plus per transformation. If you're resizing or format-converting on the fly, that cost compounds. And you're locked in: if you ever want to move images somewhere else, you'd have to rewrite all your URLs.

B2 + Cloudflare separation means I own the origin. If I wake up one day and want to migrate somewhere else, I just change a DNS CNAME. No URL rewrites, no broken links.

Why not just GitHub?

GitHub is great for source control, but it's not a CDN. Every image request comes from the GitHub servers—no caching, no edge distribution, same bandwidth hit whether someone's in Tokyo or Toronto.

Also, GitHub discourages using your repo as a generic file host. A few hundred images is fine; thousands and you're fighting against the platform's assumptions. B2 is designed for exactly this.

The B2 + Cloudflare approach

The setup is dead simple: store images in a public B2 bucket, point a Cloudflare CNAME to B2's endpoint, add some transform rules to rewrite paths, tweak the response headers for caching. That's it.

Here's the flow:

  1. Browser requests https://files.terakedis.dev/images/photo.jpg
  2. Cloudflare intercepts → applies a transform rule to add the B2 bucket prefix → requests /file/euc-rt-files/images/photo.jpg from B2
  3. B2 returns the image + response headers
  4. Cloudflare strips B2 metadata headers, sets cache to 24 hours
  5. Image gets cached globally on Cloudflare's edge → user gets it from nearby

No Workers, no Lambda functions, no complicated auth. Just DNS + rules.

The free tier + free egress math

Here's what makes this actually free for small blogs:

Backblaze B2:

  • 10 GB of egress per month, free
  • 1 GB of storage, free
  • $0.006 per GB after that (or about $6/month per TB)

Cloudflare:

  • Free tier includes DNS, SSL, and unlimited transform rules
  • No per-request fees, no overage charges
  • You can have as many rules as you want

The kicker: Backblaze has a partnership with Cloudflare. When traffic comes through Cloudflare, B2 doesn't charge egress. At all. This is huge and I see a lot of people miss it.

Right now my blog has 13 images stored on B2 (14.1 MB total). Even if I had 1,000 images, I'm still comfortably in the free tier. The partnership means every byte served through Cloudflare's cache costs me exactly $0.

You only start paying when:

  • Your B2 storage exceeds 1 GB (unlikely unless you're hosting video)
  • You hit egress from B2 that doesn't go through Cloudflare (direct B2 links, for example)

For a personal blog or small creator, this runway is essentially infinite.

Configuration to enable free egress: It's automatic—no special setup required. You just need:

  1. A public B2 bucket (readable by anyone)
  2. Cloudflare fronting it via CNAME
  3. B2's systems detect Cloudflare's IP ranges and waive egress

That's it. The partnership handles the rest.

How the Cloudflare setup works

Your Cloudflare rules do two things:

URL Rewrite Rule:

1Match: requests to files.terakedis.dev that don't already have /file/ in the path
2Action: rewrite /images/photo.jpg → /file/euc-rt-files/images/photo.jpg

This tells B2 which bucket to serve from. B2's direct API endpoint (f000.backblazeb2.com) expects paths in the format /file/<bucket-name>/...

Response Header Rules:

1Rule 1: Strip B2 metadata (ETag, x-bz-* headers)
2Rule 2: Extend cache from 4 hours to 24 hours
3Rule 3: (optional) Add CORS headers if you need cross-domain image embedding

The DNS CNAME does the rest: files.terakedis.dev → f000.backblazeb2.com

What I got right

It's cheap. I'm not paying anything, and even if I scaled to 10,000 images, I'd probably still be in the free tier.

It's durable. B2 has the same durability guarantees as AWS S3 (99.999999% — that's 11 nines). My images won't mysteriously vanish.

It's room to grow. If I ever need more bandwidth or storage, I don't rearchitect. I just start paying B2's usage fees. No migration, no URL changes.

It's decoupled. My image hosting isn't tied to my blog platform or my CDN. I can swap any piece without affecting the others.

Gotchas and the growth path

Nothing has surprised me negatively. The only gotchas are things you should know upfront:

B2's free egress only applies through Cloudflare. If you link directly to B2 (like from a social media post), that traffic is charged. So don't do that. Always link through your Cloudflare domain.

B2 versioning. By default, B2 keeps all old versions of files. This is great for recovery but eats storage. You can disable it if you want, but I've left it on for peace of mind.

Cache invalidation. Since Cloudflare caches for 24 hours, if you replace an image, it takes a day to propagate. For a blog, that's fine. If you need instant updates, you'd need to purge the cache manually or shorten the TTL.

As for the growth path: I can scale this to hundreds of thousands of images before hitting any real costs. At some point, if traffic gets huge, paying for B2 storage and bandwidth is still cheaper than Cloudflare Images or other managed solutions.

Who this is for

This setup is for anyone hosting a static site (Hugo, Jekyll, Next.js, whatever) and wants cheap, durable image storage that's easy to move later.

It's especially good if you're a solo creator or small team and don't want to manage complex infrastructure. You get CDN speeds and global distribution for free.

You probably shouldn't use this if you need image transformation on-the-fly (dynamic crops, format conversion for different devices). Cloudflare Images handles that. Or if you need upload APIs and user-generated content—that's a different problem.

But for a blog where you control the images and you're happy uploading them by hand? This is unbeatable.

The move

Setting this up took me about 20 minutes. The Cloudflare rules are the only slightly fiddly part, but the defaults work fine and I adjusted them for caching as I went.

I've been running this for eight months now with zero issues. No surprise bills, no performance problems, no weird edge cases. Just images that load fast and cost nothing.

Comments welcome!