Allison is coding...

Cloudflare + Backblaze + PicList: Building a Blog Image Host (No Domain Needed)

Previously I used a GitHub repo as an image host with PicGo, but GitHub has file-size limits and it’s a bit of abuse. Recently I finished a new setup that runs smoothly. Here’s the process.

This guide explains how to set up a free and private blog image host using Backblaze B2, Cloudflare Workers, and PicList—all without needing a domain name. It automates uploads, rewrites URLs, caches content, and protects your usage quota.

[PicList] --> (Uploads image) --> [Backblaze B2 Private Bucket]
                                      
                                      |
       [Cloudflare Worker] <-- Fetch & Cache <-- [Client Request]

Why Backblaze B2?

Among object-storage providers, Backblaze is the only one offering a free-tier without requiring payment info. By default, you must use a private bucket, but you can pay $1 to make it public. I prefer to keep payments off until I fully understand the workflow.

Other reference tutorials include:

I don’t own a domain, so I adapted most steps for my needs. This post is more of a “walk-through” than a strict tutorial—use as reference.


1. Backblaze Setup

Free Plan Overview

Backblaze B2 gives you 10 GB storage, 1 GB download/day, 2,500 class‑B transactions, and 2,500 class‑C transactions—all free. That’s plenty for blog image hosting.

Create a Private Bucket

Sign up on Backblaze, create a private bucket with a complex name.

In Bucket Settings → Bucket Info, add: {"cache-control": "public, max-age=86400"}.This instructs caching for one day (86400 seconds).

Note the bucket endpoint—you’ll need it later.

Create Application Keys

Generate two Backblaze application keys:

  • One with Read & Write access (for uploading via PicList)
  • One with Read Only access (for reading via Cloudflare Workers)

Copy the keyID, keyName, and applicationKey—they appear only once.


2. Cloudflare Setup

Deploy a Cloudflare Worker

Use this Worker code to proxy and serve images via Cloudflare:

export default {

  async fetch(request, env) {

    const API_KEY_ID = env.API_KEY_ID; // Application Key ID
    const API_KEY = env.API_KEY; // Application Key
    const BUCKET_NAME = env.BUCKET_NAME; // Bucket Name
    const KV_NAMESPACE = env.KV_NAMESPACE; // KV namespace for rate limiting
    
    const ALLOWED_REFERERS = ["https://site1/",
                              "https://site2/",
                              "https://site3/",
                              "https://localhost:1313/"
                            ]; // List of allowed referers

    const MAX_REQUESTS_PER_DAY = 1000; // Limit to 1000 requests per day
    const url = new URL(request.url);
    const filePath = url.pathname.slice(1); // Extract file path from URL

    if (!filePath) {
      return new Response("File path not provided", { status: 400 });
    }

    // Step 1: Validate Referer Header

    const referer = request.headers.get("Referer");
    const isAllowedReferer =
      !referer || ALLOWED_REFERERS.some((allowed) => referer.includes(allowed));

    if (!isAllowedReferer) {
      return new Response("Invalid referer", { status: 403 });
    }

    // Access Cloudflare Cache API
    const cache = caches.default;

    // Check if the response is already in cache

    const cachedResponse = await cache.match(request);
    if (cachedResponse) {
      console.log(`Cache hit for: ${filePath}`);
      return cachedResponse; // Serve from cache
    }

    // Step 2: Rate Limiting using KV
    const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
    const requestCountKey = `${currentDate}:${filePath}`;
    let requestCount = (await KV_NAMESPACE.get(requestCountKey)) || 0;

    if (requestCount >= MAX_REQUESTS_PER_DAY) {
      return new Response("Daily limit reached for this file", { status: 429 });
    }

    requestCount = parseInt(requestCount) + 1;
    await KV_NAMESPACE.put(requestCountKey, requestCount, { expirationTtl: 86400 }); // Reset every 24 hours

    console.log(`Request count for ${filePath}: ${requestCount}`);

    console.log(`Cache miss for: ${filePath}. Fetching from Backblaze.`);

    // Step 3: Authenticate with Backblaze

    const authResponse = await fetch("https://api.backblazeb2.com/b2api/v2/b2_authorize_account", {
      headers: {
        Authorization: `Basic ${btoa(`${API_KEY_ID}:${API_KEY}`)}`,
      },
    });

    const authResponseText = await authResponse.text();

    if (!authResponse.ok) {
      return new Response(`Authorization failed: ${authResponseText}`, { status: 500 });
    }

    const authData = JSON.parse(authResponseText);
    const authToken = authData.authorizationToken;
    const downloadUrl = authData.downloadUrl; // Base URL for the bucket

    // Step 4: Construct the file URL

    const b2FileUrl = `${downloadUrl}/file/${BUCKET_NAME}/${filePath}`;

    // Step 5: Fetch the file from Backblaze

    const fileResponse = await fetch(b2FileUrl, {
      headers: {
        Authorization: authToken,
      },
    });

    if (!fileResponse.ok) {
      const errorText = await fileResponse.text();
      return new Response(`Failed to fetch file from Backblaze: ${errorText}`, { status: 404 });
    }

    // Clone the response to store in cache
    const responseToCache = new Response(fileResponse.body, {
      headers: {
        "Content-Type": fileResponse.headers.get("Content-Type"),
        "Cache-Control": "public, max-age=31536000", // Cache for 1 year
      },
    });

    // Store the response in cache

    await cache.put(request, responseToCache.clone());
    // Return the fetched response to the client
    return responseToCache;
  },
};

Key features:

  • Reads filePath from request URL
  • Validates Referer header against allowed list
  • Caches responses in Cloudflare edge cache
  • Applies rate‑limiting using KV namespace
  • Authenticates and fetches files from Backblaze
  • Sets cache-control for 1‑year CDN cache once fetched

Replace placeholders with your Backblaze bucket info, application‑key details, KV namespace, and referer list. Configure rate limits (default 1 000 requests/day).

Set Secrets in Cloudflare Worker

Inside the Cloudflare dashboard, after you’ve created your Worker:

  1. Go to your Worker’s settings.
  2. Open the “Settings” → “Variables” → “Environment Variables” section (or go to “Secrets” if you’re using Wrangler CLI).
  3. Create the following secrets:
NameDescription
API_KEY_IDThe Key ID of your Backblaze read-only key.
API_KEYThe actual Application Key for that same key.
BUCKET_NAMEThe name of your Backblaze B2 bucket.

Create Cloudflare KV Namespace

The Worker above uses a KV namespace to store how many times each image has been accessed. If the same file is requested more than a set limit (like 1,000 times per day), the Worker will stop serving it temporarily. This prevents abuse and protects your free Backblaze quota without needing a login system.

Create a KV Namespace
  • Go to your Cloudflare dashboard.
  • Navigate to Workers > KV > Create a Namespace.
  • Give your namespace a name (e.g., daily-usage-tracker).
Bind the Namespace to Your Worker
  • After creating the namespace, you’ll need to bind it to your Worker.
  • Go to your Worker script in the Cloudflare dashboard.
  • Under the Settings tab, find the KV Namespace Bindings section.
  • Add a binding by giving it a name (e.g., KV_NAMESPACE) and selecting the namespace you created earlier.
Use the Namespace in Your Script
  • The binding (KV_NAMESPACE) will be available in the env object passed to your Worker script.

Summary

So the complete setup flow should include:

  1. Backblaze B2: Create private bucket + read-only key.
  2. Cloudflare Worker: Deploy image-proxying script.
  3. Secrets: Inject credentials (API_KEY_ID, API_KEY, BUCKET_NAME) into the Worker environment.
  4. KV Namespace: Use Cloudflare KV to implement per-file rate-limiting.

3. PicList Setup

What is PicList?

PicList is a PicGo plugin that acts as a GUI front-end for uploading images to various platforms—including S3-compatible services like Backblaze.

Think of it as the bridge between your local clipboard and your custom image hosting backend.

Key Points in the PicList Setup

  1. Configure B2 Access:
    • Use your key ID and application key (read & write) as AccessKeyID and SecretAccessKey for authentication
    • Set Self Endpoint to Backblaze’s S3-compatible endpoint, e.g. https://{endpoint}
    • Set ACL: private
    • Bucket: Your private bucket name
    • Set Cloudflare Worker url as Self Custom URL, this rewrites https://f000.backblazeb2.com/file/bucket-name/path/to/image.jpg into https://your-cloudflare-subdomain.workers.dev/path/to/image.jpg.
  2. Seamless Copy + Paste Experience:
    • Once configured, you can just paste images into PicList, and it will:
      • Upload to B2
      • Return the proxied Cloudflare Worker URL
      • Copy it directly to your clipboard (e.g., Markdown image tag)

This completes the loop: you never see or touch the raw B2 URL, and your images are always routed through your cacheable, rate-limited, domain-free, proxy-controlled CDN.

Note: PicList must use the read/write application key, while the Cloudflare Worker should only use a read-only key for security.