I host a 380-image gallery on Cloudflare's free plan. Here's the setup that keeps it at $0
I built Skynett Images, a gallery of AI-generated images that anyone can download for free (PNG, JPG or WebP, commercial use allowed, every image labelled as AI-generated). Image sites have a reputation for getting expe
I built Skynett Images, a gallery of AI-generated images that anyone can download for free (PNG, JPG or WebP, commercial use allowed, every image labelled as AI-generated).
Image sites have a reputation for getting expensive: every full-size download is a few megabytes of bandwidth. I wanted to see if I could run one entirely on Cloudflare's free plan without worrying about a surprise bill. This is the setup, what it costs today ($0), and a couple of gotchas that cost me time.
The architecture
Everything runs in one Worker:
Browser
│
▼
Cloudflare Worker ──► Static assets (HTML, WebP thumbnails, JPEG previews)
│
├─► /api/images ──► D1 (catalog, cursor pagination)
├─► /img/<id>.png ──► R2 (full-size originals, ~3 MB each)
└─► /image/<id> ──► server-rendered page from a bundled catalog
-
Static assets (HTML, thumbnails, previews) are served by the Workers Assets binding. The Worker only runs first on the routes that need it, using
run_worker_firstwith a list of paths:
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/", "/image/*", "/api/*", "/img/*", "/ingest/*"]
}
- R2 holds the PNG originals (about 1 GB for 380 images). The bucket has no public access at all: the r2.dev URL is off and there is no custom domain on it. Originals are streamed through the Worker, which lets me rate-limit and budget them.
-
D1 holds the catalog. The gallery uses infinite scroll, so
/api/imagesuses cursor pagination over a pre-shuffledsortcolumn. The order is fixed per catalog version, so pages never shift or repeat while someone scrolls.
Fast first paint without a framework
The first version rendered the gallery entirely in JavaScript, and Lighthouse mobile performance was 73 (LCP 5.9 s). Two changes brought it to 94:
-
HTMLRewriteron the home page. The Worker takes the staticindex.htmland injects the first 12 cards, the page title and meta tags for the selected category, and JSON-LD. The browser gets real content in the first response, and the JavaScript picks up from there.
// simplified
return new HTMLRewriter()
.on('title', { element(el) { el.setInnerContent(meta.title); } })
.on('#gallery', { element(el) { el.setInnerContent(firstCards, { html: true }); } })
.on('head', { element(el) { el.append(jsonLd, { html: true }); } })
.transform(await env.ASSETS.fetch(request));
-
Right-sized images. Cards use
srcsetwith 360 px and 560 px WebP thumbnails, the first five load eagerly, and each image page uses a 900 px JPEG preview. The multi-megabyte PNG only loads when someone opens or downloads an image.
Every image also has its own page at /image/<id>. These are rendered from a catalog JSON that is bundled into the Worker, so they need zero database reads. That matters for search engines, which crawl hundreds of these pages.
Keeping it at $0: the guardrails
Free tiers are generous, but I wanted hard limits rather than hope.
1. Rate limiting binding. Per IP: 60 full-size images and 120 API calls per minute.
"ratelimits": [
{ "name": "IMG_LIMITER", "namespace_id": "4101", "simple": { "limit": 60, "period": 60 } },
{ "name": "API_LIMITER", "namespace_id": "4102", "simple": { "limit": 120, "period": 60 } }
]
2. A read budget that fails closed. The Worker counts R2 reads per day and per month in a small D1 table. Past 200,000 a day or 6 million a month, it stops serving originals instead of running up a bill. Thumbnails and previews keep working, so the site degrades instead of going down.
3. Hotlink protection. Originals are only served to requests coming from the site's own pages, so nobody can embed the full-size files elsewhere and use my quota.
4. Edge caching. HTML and API responses are cached in caches.default for 5 minutes, keyed by a version string. Bumping the version on deploy invalidates everything at once.
5. A guardrail check script that I run after changes: it confirms the bucket has no public URL, the storage class is Standard (covered by the free tier), and prints the current read counters.
Current usage, with real traffic from launch day: about 4% of D1's daily read limit, about 10% of R2 storage, and R2 egress is free anyway.
Gotchas
A Cloudflare setting blocked Googlebot. This was the big one. In AI Crawl Control, the "Training: Block" policy blocked real Googlebot and Bingbot requests (Cloudflare's security log showed 403s for /sitemap.xml). Search Console kept saying "Couldn't fetch" for two days. Setting Training to Allow and moving the AI-training opt-out into robots.txt fixed it within minutes. If you proxy a new site through Cloudflare, check Security → Events for Googlebot before assuming Google is just slow.
A custom hostname held by another account. A hostname still attached to another Cloudflare account through Cloudflare for SaaS silently overrode my Worker's custom domain until it was removed there.
compatibility_date. Wrangler refuses a date newer than the version you have installed, so pin it to one your wrangler knows.
Was it worth it?
For a small image site, yes. Static hosting would have been simpler, but I wouldn't have server-rendered pages, real cache control, private originals or cost limits I can trust.
The site is here if you want to look at the result: images.skynettstudio.com. Happy to answer questions about any part of the setup in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.