Free image hosting: 10 GB on Backblaze B2 behind a Cloudflare Worker

October 25, 2026 · 5 min read

Backblaze B2 gives you the first 10 GB of storage for free. Put a Cloudflare Worker in front of a private bucket and you have image hosting for a small site that costs nothing and, set up the right way, can't turn into a bill. We're setting this up for a student forum, so the examples use paths like threads/<uuid>.webp. Nothing here depends on the forum, though.

Storage is free up to 10 GB, and so are most downloads

According to Backblaze's pricing page, the first 10 GB of storage are free. Downloads are free up to three times the data you store, and unlimited when they go to or through one of Backblaze's partner CDNs. The pricing FAQ names Cloudflare among them. API calls come in classes: Class A, B and C calls are free, and only Class D costs money, with the first 2,500 a day free.

The other half is a Worker on Cloudflare's free plan. It gets 100,000 requests a day, and the counter resets at 00:00 UTC. Past the limit, requests fail instead of billing. The Worker sits in front of a private bucket and is the only thing that reads from it.

Your usersany browser
  • A
    Browser

    compresses first

    WebP (JPEG on Safari), longest side 1,600 px, at most 1 MB, no EXIF or location.

    ≤ 1 MB

Never uploads straight to the bucket.

Your appyour own server
  • Your server

    server action or API route

    upload key: read + write
EdgeCloudflare Workers Free
  • Cloudflare Worker

    GET/HEAD on /threads/<uuid>.webp only

    100k req/dayread-only key
  • Edge cache

    per data center, not replicated

    custom domain onlymax-age 1 day
StorageBackblaze B2, private
  • Private bucket

    no public URL

    threads/<uuid>.webp

    10 GB free
  • -dev bucket

    localhost + previews

    expires ~1 week

Upload A–C, view 1–4

  1. A

    Browser

    Compresses the image before anything is sent.

  2. B

    Browser → Your server

    Sends it through a server action or API route. The server checks the real format.

  3. C

    Your server → Private bucket

    Writes it with the upload key, which can read and write that one bucket.

  4. 1

    Browser → Worker

    Asks for /threads/<uuid>.webp. Anything else gets a 405 or 404.

  5. 2

    Worker → Edge cache

    A hit goes straight back. The cache works only on a custom domain, not workers.dev.

  6. 3

    Worker → Private bucket

    On a miss, a SigV4-signed GET with the read-only key.

  7. 4

    Private bucket → Worker → Browser

    The image streams back and goes into the cache. Egress to Cloudflare is free.

  • request or data
  • 1steps, explained below
Upload: (A) the browser compresses, (B) sends the file to your server, (C) the server writes it with the read-write upload key. View: (1) the browser asks the Worker, (2) the Worker checks the edge cache and a hit returns at once, (3) a miss becomes a SigV4-signed GET with the read-only key, (4) the image streams back, free of egress to Cloudflare, and lands in the cache. The bucket itself is never public.

A private bucket behind a Worker beats a public bucket URL

B2 can serve a public bucket directly, so why add a Worker? Because a public URL gives away more than images. The bucket's name is in every link, and anyone can ask for any key in it. With a Worker in front, the bucket stays private, and its name and keys never reach the browser. You decide which paths can be served at all, and you set the cache headers yourself.

The Worker also brings Cloudflare's edge cache, which absorbs repeat views, so the same image isn't fetched from B2 again and again. And the failure mode is the right one. If a bot hammers your images, the free plan stops at its daily limit instead of sending a bill. In our experience Backblaze can't bill either while the account has no card on it. Going past 10 GB would need a card, and then Backblaze's daily spending caps are the thing to set.

The edge cache only works on your own domain

This one is easy to miss. Cloudflare's Cache API docs say that “Workers deployed to custom domains have access to functional cache operations.” So the cache needs the Worker on a domain you own, for example a media. subdomain. On a workers.dev address, treat caching as not working. Every view then goes to B2. That's still free, just slower. And every view counts against the Worker's 100,000 daily requests either way, cached or not.

The cache is also per data center. It isn't replicated, so a visitor served by another data center can miss even when the image is cached somewhere else.

The Worker does one small job

It answers GET and HEAD; anything else gets a 405. The path has to match a strict pattern for random keys, like /threads/<uuid>.webp, or the answer is a 404. That stops the Worker from becoming a general reader for the whole bucket.

Then it checks the edge cache. On a miss, it signs an S3 request with SigV4, using the tiny aws4fetch library, and fetches https://s3.<region>.backblazeb2.com/<bucket>/<key>. The key it signs with is read-only and stored as Worker secrets. The body streams straight back to the browser.

The response gets Cache-Control: public, max-age=86400, so a deleted image stops loading within a day without a cache purge. It also gets X-Content-Type-Options: nosniff. A 404 is cached briefly. An upstream error becomes a 502 and isn't cached. The cache write goes into ctx.waitUntil, so the visitor doesn't wait for it.

import { AwsClient } from "aws4fetch";

const KEY = /^\/threads\/[0-9a-f-]{36}\.(webp|jpg)$/;

export default {
  async fetch(req, env, ctx) {
    const path = new URL(req.url).pathname;
    if (req.method !== "GET") return new Response(null, { status: 405 });
    if (!KEY.test(path)) return new Response("Not found", { status: 404 });

    const hit = await caches.default.match(req);
    if (hit) return hit;

    const b2 = new AwsClient({
      accessKeyId: env.B2_KEY_ID,
      secretAccessKey: env.B2_APPLICATION_KEY,
      service: "s3",
      region: env.B2_REGION,
    });
    const res = await b2.fetch(`https://s3.${env.B2_REGION}.backblazeb2.com/${env.B2_BUCKET}${path}`);
    if (!res.ok) return new Response(null, { status: res.status === 404 ? 404 : 502 });

    const out = new Response(res.body, res);
    out.headers.set("Cache-Control", "public, max-age=86400");
    out.headers.set("X-Content-Type-Options", "nosniff");
    ctx.waitUntil(caches.default.put(req, out.clone()));
    return out;
  },
};
Simplified. A full version also answers HEAD and caches 404s for a short time.

Uploads go through your server, never straight to the bucket

The browser never talks to B2. An upload goes from the browser to your own server, through a server action or an API route, and from there to the bucket. The server uses a separate upload key that can read and write that one bucket and nothing else. The Worker's read-only key stays a different secret.

Compress in the browser first, so there's less to send and less to store. Re-encode to WebP, with JPEG as the fallback because Safari can't encode WebP. Keep the longest side around 1,600 px and the file at 1 MB or less. Re-encoding also drops EXIF and location data. Then have the server sniff the real format from the bytes instead of trusting the file name, and reject a mismatch. Name objects with random keys, so nobody can guess the next one.

Hard caps keep you inside 10 GB

Compressed images are small, but a bug or a bot can still fill 10 GB. Put the caps where a frontend bug can't skip them, for example in database triggers: a limit per post, per user per day, site-wide per day, and a total-storage stop below 10 GB. Ours stops at 8 GB, to leave room for other files in the same account.

Keep a separate dev bucket for local and preview builds, with its own keys, and add a lifecycle rule that expires files there after a week. Test uploads then clean themselves up.

The whole setup in five steps

Create a private B2 bucket and two keys limited to it: a read-only key for the Worker and a read-write key for your server. Deploy the Worker with the read-only key as secrets, on your own domain so the edge cache works. Upload through your server, compressing in the browser and checking the bytes on the server. Put the caps in the database, below 10 GB. And leave the card off the Backblaze account until you actually need more than the free tier.