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:
| Service | Purpose | Free Quota |
|---|---|---|
| R2 Object Storage | Store image files | 10 GB storage / 10M GET requests/month |
| D1 Database | Store image metadata | 5 GB storage / 5M reads/day |
| Workers | API backend | 100K requests/day |
| Pages | Frontend website | Unlimited 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 / UserFour 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/searchand/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-dbNote 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.sqlStep 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-libraryCSS 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):
| Item | Usage | Cost |
|---|---|---|
| R2 Storage | ≈ 2.5 GB | $0 |
| D1 Reads (1,000 daily visitors) | 5,000/day | $0 |
| Workers Requests | 5,000/day | $0 |
| Pages Deploys | Unlimited | $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:
- R2 stores the files — cheap and fast
- D1 stores the index — powers search
- Workers handles query logic
- Pages delivers the UI
- 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