Skip to content

Build Your Own Image Library with Cloudflare's Free Plan — Starting at $0

Have you ever wished you could centralize all your go-to royalty-free images into one searchable hub — without paying a cent?

We built one. This article explains the full architecture.


Why Build Your Own

Designers, web developers, and marketers constantly need images. Pixabay, Pexels, and Unsplash are great, but opening each one separately and searching independently wastes time.

Worse: you used an image once, and now you can't remember where you found it.

A unified image library — one search across all sources — is the real time-saver.

That's the idea behind FreePicHub.


How Generous Is Cloudflare's Free Tier?

Most people underestimate how much Cloudflare gives away for free. Here's what this project actually uses — all within the free plan:

ServicePurposeFree Quota
R2 Object StorageStore image files10 GB storage / 10M GET requests/month
D1 DatabaseStore image metadata5 GB storage / 5M reads/day
WorkersAPI backend100K requests/day
PagesFrontend websiteUnlimited static deploys

For a small-to-medium personal image library, this quota is more than enough — and no credit card required to start.


Architecture Overview

[Python Ingestion Script]

  Image Processing (WebP compression)

 ┌──────────────────┐
 │    Cloudflare    │
 │                  │
 │  R2 (files)      │
 │  D1 (database)   │
 │  Workers (API)   │
 │  Pages (UI)      │
 └──────────────────┘

    Browser / User

Four Cloudflare services, each with a clear role:

  • R2: Stores compressed image files (thumbnail / preview / original)
  • D1: SQLite database — titles, dimensions, tags, R2 file paths
  • Workers: Two APIs — /api/search and /api/featured
  • Pages: Static frontend, pure HTML + CSS + JS, no framework dependency

Step 1: Create an R2 Bucket

Go to Cloudflare Dashboard → R2 → Create Bucket (e.g. my-image-library).

Then under Manage R2 API Tokens, create a User API Token:

  • Permission: "Object Read & Write"
  • Scoped to your specific bucket

Note down the Access Key ID, Secret Access Key, and Account ID — your Python script will need these.

R2 uses an S3-compatible API, so boto3 works directly — no new SDK to learn.


Step 2: Create a D1 Database

Run in Wrangler CLI:

bash
npx wrangler d1 create my-image-db

Note the returned database_id. Then create the schema:

sql
CREATE TABLE IF NOT EXISTS images (
  id            TEXT PRIMARY KEY,
  source        TEXT NOT NULL,
  source_id     TEXT,
  title         TEXT,
  tags          TEXT DEFAULT '[]',
  width         INTEGER,
  height        INTEGER,
  aspect_ratio  TEXT,
  license_type  TEXT NOT NULL,
  thumb_key     TEXT,
  preview_key   TEXT,
  original_key  TEXT,
  created_at    TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_source    ON images(source);
CREATE INDEX IF NOT EXISTS idx_created   ON images(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_source_id ON images(source, source_id);
bash
npx wrangler d1 execute my-image-db --file=schema.sql

Step 3: Workers API (via Pages Functions)

The easiest approach is Cloudflare Pages Functions — put JS files in /functions/ and Cloudflare auto-deploys them as Workers.

Search API (functions/api/search.js):

javascript
export async function onRequestGet({ request, env }) {
  const url    = new URL(request.url)
  const q      = url.searchParams.get('q') || ''
  const source = url.searchParams.get('source') || ''
  const page   = parseInt(url.searchParams.get('page') || '1')
  const limit  = 24
  const offset = (page - 1) * limit

  let sql    = 'SELECT * FROM images WHERE 1=1'
  let params = []

  if (q) {
    sql += ' AND (title LIKE ? OR tags LIKE ?)'
    params.push(`%${q}%`, `%${q}%`)
  }
  if (source) {
    sql += ' AND source = ?'
    params.push(source)
  }

  sql += ` ORDER BY created_at DESC LIMIT ${limit + 1} OFFSET ${offset}`

  const { results } = await env.MY_DB.prepare(sql).bind(...params).all()
  const hasMore = results.length > limit

  return Response.json({
    results: results.slice(0, limit).map(r => ({
      ...r,
      tags: JSON.parse(r.tags || '[]')
    })),
    has_more: hasMore
  }, { headers: { 'Access-Control-Allow-Origin': '*' } })
}

Step 4: Image Processing & Automated Ingestion

This is the most interesting part. A Python script reads image sources (APIs or local folder), processes each image, uploads to R2, and writes metadata to D1.

Image processing — three versions per image, all saved as WebP:

python
from PIL import Image

def process_image(source_path, output_dir):
    img = Image.open(source_path).convert("RGB")
    w, h = img.size

    # Thumbnail 512px (gallery list)
    thumb = img.copy()
    thumb.thumbnail((512, 512), Image.LANCZOS)
    thumb.save(output_dir / "thumb.webp", "WEBP", quality=82)

    # Preview 1024px (modal / lightbox)
    preview = img.copy()
    preview.thumbnail((1024, 1024), Image.LANCZOS)
    preview.save(output_dir / "preview.webp", "WEBP", quality=85)

    # Original converted to compressed WebP
    img.save(output_dir / "original.webp", "WEBP", quality=90)

    return {"width": w, "height": h}

WebP typically runs 25–35% smaller than JPEG — meaningful savings over time in R2 storage.

Upload to R2 (boto3 S3-compatible):

python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
    aws_access_key_id=ACCESS_KEY,
    aws_secret_access_key=SECRET_KEY,
    region_name="auto",
)
s3.put_object(Bucket="my-image-library", Key="source/uuid/thumb.webp", Body=file_bytes)

Write to D1 — duplicate-safe:

python
import requests, uuid, json

def write_to_d1(record):
    resp = requests.post(
        f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/d1/database/{DB_ID}/query",
        headers={"Authorization": f"Bearer {D1_TOKEN}"},
        json={
            "sql": """
                INSERT INTO images (id, source, source_id, title, tags,
                  width, height, aspect_ratio, license_type,
                  thumb_key, preview_key, original_key)
                SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
                WHERE NOT EXISTS (
                  SELECT 1 FROM images WHERE source=? AND source_id=?
                )
            """,
            "params": [str(uuid.uuid4()), record["source"], record["source_id"],
                       record["title"], json.dumps(record["tags"]),
                       record["width"], record["height"], record["aspect_ratio"],
                       record["license_type"], record["thumb_key"],
                       record["preview_key"], record["original_key"],
                       record["source"], record["source_id"]]
        }
    )
    return resp.json().get("success", False)

The WHERE NOT EXISTS clause means you can re-run the script any number of times without creating duplicate records — essential for a daily automated pipeline.


Step 5: Frontend Deployment

Three static files — index.html, style.css, app.js — pushed to Cloudflare Pages:

bash
npx wrangler pages deploy ./frontend --project-name my-image-library

CSS masonry waterfall layout, no JS library needed:

css
.image-grid {
  columns: 4 220px;
  gap: 14px;
}
.image-card {
  break-inside: avoid;
  margin-bottom: 14px;
}

Cost Breakdown

Assuming 500 images ingested per day, library at 10,000 images (thumbnail ~50KB, preview ~200KB):

ItemUsageCost
R2 Storage≈ 2.5 GB$0
D1 Reads (1,000 daily visitors)5,000/day$0
Workers Requests5,000/day$0
Pages DeploysUnlimited$0
Total$0 / month

Cloudflare's free tier is practically inexhaustible for personal or small commercial libraries. When you hit hundreds of thousands of images and thousands of daily visitors — that's when you evaluate upgrading.


Summary

The core idea is simple:

  1. R2 stores the files — cheap and fast
  2. D1 stores the index — powers search
  3. Workers handles query logic
  4. Pages delivers the UI
  5. Python scripts feed data in on a schedule

Everything runs on Cloudflare's edge network — globally accelerated, zero maintenance overhead.

If you want to build your own image library, the technical bar is lower than you'd expect. With basic Python and JavaScript, this architecture can be running in a weekend.

See the live result: FreePicHub


Questions about technical specifications or architecture?
Feel free to reach out at ascentek.info — whether it's Cloudflare architecture design, Python automation pipelines, or image library planning, we're happy to discuss.


Further Reading

Ascentek Digital Knowledge Base