<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Skynett Studio</title>
    <description>The latest articles on DEV Community by Skynett Studio (@skynettstudio).</description>
    <link>https://dev.to/skynettstudio</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4244274%2F6eb62232-33ee-4a97-b652-684ff157b27d.png</url>
      <title>DEV Community: Skynett Studio</title>
      <link>https://dev.to/skynettstudio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/skynettstudio"/>
    <language>en</language>
    <item>
      <title>I host a 380-image gallery on Cloudflare's free plan. Here's the setup that keeps it at $0</title>
      <dc:creator>Skynett Studio</dc:creator>
      <pubDate>Sat, 14 Nov 2026 10:17:02 +0000</pubDate>
      <link>https://dev.to/skynettstudio/i-host-a-380-image-gallery-on-cloudflares-free-plan-heres-the-setup-that-keeps-it-at-0-205i</link>
      <guid>https://dev.to/skynettstudio/i-host-a-380-image-gallery-on-cloudflares-free-plan-heres-the-setup-that-keeps-it-at-0-205i</guid>
      <description>&lt;p&gt;I built &lt;a href="https://images.skynettstudio.com/?utm_source=devto" rel="noopener noreferrer"&gt;Skynett Images&lt;/a&gt;, 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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Everything runs in &lt;strong&gt;one Worker&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
  │
  ▼
Cloudflare Worker  ──►  Static assets (HTML, WebP thumbnails, JPEG previews)
  │
  ├─► /api/images  ──►  D1 (catalog, cursor pagination)
  ├─► /img/&amp;lt;id&amp;gt;.png ──►  R2 (full-size originals, ~3 MB each)
  └─► /image/&amp;lt;id&amp;gt;   ──►  server-rendered page from a bundled catalog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static assets&lt;/strong&gt; (HTML, thumbnails, previews) are served by the Workers Assets binding. The Worker only runs first on the routes that need it, using &lt;code&gt;run_worker_first&lt;/code&gt; with a list of paths:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"./dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ASSETS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"run_worker_first"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/image/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/api/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/img/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/ingest/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; holds the catalog. The gallery uses infinite scroll, so &lt;code&gt;/api/images&lt;/code&gt; uses &lt;strong&gt;cursor pagination&lt;/strong&gt; over a pre-shuffled &lt;code&gt;sort&lt;/code&gt; column. The order is fixed per catalog version, so pages never shift or repeat while someone scrolls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fast first paint without a framework
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTMLRewriter&lt;/code&gt; on the home page.&lt;/strong&gt; The Worker takes the static &lt;code&gt;index.html&lt;/code&gt; and 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.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HTMLRewriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInnerContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'#gallery'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInnerContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstCards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Right-sized images.&lt;/strong&gt; Cards use &lt;code&gt;srcset&lt;/code&gt; with 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every image also has its own page at &lt;code&gt;/image/&amp;lt;id&amp;gt;&lt;/code&gt;. These are rendered from a catalog JSON that is &lt;strong&gt;bundled into the Worker&lt;/strong&gt;, so they need zero database reads. That matters for search engines, which crawl hundreds of these pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it at $0: the guardrails
&lt;/h2&gt;

&lt;p&gt;Free tiers are generous, but I wanted hard limits rather than hope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rate limiting binding.&lt;/strong&gt; Per IP: 60 full-size images and 120 API calls per minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"ratelimits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"IMG_LIMITER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"namespace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"4101"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"simple"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"API_LIMITER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"namespace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"4102"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"simple"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. A read budget that fails closed.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Hotlink protection.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Edge caching.&lt;/strong&gt; HTML and API responses are cached in &lt;code&gt;caches.default&lt;/code&gt; for 5 minutes, keyed by a version string. Bumping the version on deploy invalidates everything at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A guardrail check script&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A Cloudflare setting blocked Googlebot.&lt;/strong&gt; 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 &lt;code&gt;/sitemap.xml&lt;/code&gt;). Search Console kept saying "Couldn't fetch" for two days. Setting Training to Allow and moving the AI-training opt-out into &lt;code&gt;robots.txt&lt;/code&gt; fixed it within minutes. If you proxy a new site through Cloudflare, check &lt;strong&gt;Security → Events&lt;/strong&gt; for Googlebot before assuming Google is just slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom hostname held by another account.&lt;/strong&gt; A hostname still attached to another Cloudflare account through Cloudflare for SaaS silently overrode my Worker's custom domain until it was removed there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compatibility_date&lt;/code&gt;.&lt;/strong&gt; Wrangler refuses a date newer than the version you have installed, so pin it to one your wrangler knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The site is here if you want to look at the result: &lt;a href="https://images.skynettstudio.com/?utm_source=devto" rel="noopener noreferrer"&gt;images.skynettstudio.com&lt;/a&gt;. Happy to answer questions about any part of the setup in the comments.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
