Their shop costs ₹0 a month. A finished feature stays off to keep it that way.
Prajjwal Pathak24 min
- date
- words
- 4 694
- read
- 24 min
- sources
- 5 sources
- running cost
- ₹0/mo running cost
- homepage weight
- 3.7 MB → 233 KB
- tests
- 313 tests
- transforms used
- 60 / 5,000
- worker startup
- 7 ms
- D1 tables
- 17 D1 tables
- views
- 6 views
Snackly sells dry fruits, nuts and seeds. Their storefront and the admin panel behind it run on Cloudflare, take real payments, and cost them nothing per month to operate. Not “nearly nothing” — the invoice does not exist.
That was the constraint, not the achievement. A small Indian retailer will absorb a one-off build cost and will not absorb a recurring one, and a hosting bill is the thing that quietly kills a small store’s website about a year in: the card expires, nobody notices, and the site is gone. So the brief was to build a real shop — catalogue, cart, checkout, inventory, orders, coupons, content — inside allowances that never generate a charge.
Cloudflare’s free tiers make that possible for a specific and slightly unusual reason. They fail closed. Exceed the Workers request limit and further operations return an error. Exceed D1’s daily row reads and queries are blocked. Exceed the image transformation allowance and you get a 9422 back, and Cloudflare’s own documentation says plainly that “you will not be charged for exceeding the limits in the Free plan.” None of these degrade into a bill.
That is the whole reason this is safe to hand a client. The failure mode is downtime, not debt — and downtime is a problem you can be called about, while a surprise ₹4,000 charge on somebody’s personal card is a problem you find out about when the relationship ends.
The most useful thing in the repository is not the architecture. It is a feature that is built, tested, deployed and deliberately switched off, because turning it on would have started a bill in the client’s name against a sentence we had already written down.
TL;DR
- Static asset requests are free and unlimited, and that single line is the architecture. The storefront is a Next.js static export served by Workers Static Assets, so a customer browsing forty pages never reaches our code. Only
/api/*runs the Worker, which is what keeps the whole store inside 100,000 requests a day. - Three separate pieces of code exist in their current shape because of a metered limit. Image widths are clamped to three buckets because Next’s default eight would burn the transformation budget; the admin dashboard reads a nightly aggregate because summing the orders table live is the one query capable of exhausting 5 million daily row reads by itself; the catalogue runs three flat queries instead of one JOIN because D1 is metered in rows read, and a JOIN reads far more rows for the same answer.
- The order-alert emails are finished and switched off. Cloudflare Email Sending requires the Workers Paid plan. The scope document told the client twice that their monthly platform cost is zero. We had misread the pricing page and said otherwise, in writing, twice — so the binding ships commented out with the reason attached rather than quietly contradicting the promise.
- The free tier is metered per account, so the store had to be built in the client’s account, not ours. Two clients in one account share one allowance, and a busy Diwali on one store throttles the other. It also turns handover into a role grant rather than a migration.
- The admin has no login form — no password table, no session store, no reset email. Cloudflare Access gates it at the edge and the Worker re-verifies the signed assertion, because anyone who learns the origin URL can call it directly.
- Image transformations took the homepage from 3,746 KB to 233 KB on a phone, and the store is using 60 of its 5,000 monthly transformations.
The free tier is a shape, not a discount
Building for a free tier is not the same as building cheaply. Every limit is a different kind of limit, and the ones that bite are rarely the ones you plan around.
| Service | What it does here | Free allowance |
|---|---|---|
| Workers Static Assets | every storefront page, script and stylesheet | free and unlimited |
| Workers | the API and the admin logic | 100,000 requests/day, 10 ms CPU each |
| D1 | products, orders, customers, content | 5 M rows read/day · 100 k written/day · 5 GB |
| R2 | product photography | 10 GB · 1 M Class A · 10 M Class B · zero egress |
| Image Transformations | resizing and format negotiation | 5,000 unique transformations/month |
| Access | the admin login | 50 users |
| Cron Triggers | nightly aggregates and backup | 5 per account |
Read that as a shape and two things stand out immediately.
The first is that the tightest limit is not requests or storage. It is 5,000 image transformations a month, and it is tight in a way that is easy to miss because the number sounds generous.
The second is a trap. KV allows 1,000 writes a day. That is small enough that carts, sessions or view counters in KV would work perfectly in development and fail on a moderately busy Saturday. This build uses no KV at all.
Neither of those is a performance consideration. They are both arithmetic, done before the code was written, and both changed what got built.
One line decides the architecture
Requests to static assets are free and unlimited. They do not draw down the 100,000, and they never execute your Worker.
Everything else follows from that. The storefront is a Next.js static export — plain HTML in a directory — served by Cloudflare’s Asset Worker. run_worker_first in the config lists exactly /api/*, so a customer loading the homepage, browsing four categories and reading three policy pages costs zero requests against the daily cap. Only adding to a cart, placing an order or an admin action reaches the Worker at all, where a small Hono app does the routing.
Server rendering was rejected on numbers rather than taste. The free plan gives 10 ms of CPU per invocation, and server-side rendering typically lands somewhere between 10 and 20 ms — straddling the limit rather than clearing it. The Worker size cap is 3 MiB compressed on free against 10 MiB on paid, which a Next.js SSR bundle is not comfortably inside either. Both constraints point the same way.
The cost of that choice is real and worth stating: a static page is built at deploy time and the catalogue changes whenever the client edits it. The build resolves this by fetching live values — price, stock, offers — client-side at render, so a static page is never a stale page. It does not resolve it completely, and §8 is what that costs.
Three places the free tier changed the code
This is the part that generalises. Each of these is ordinary code that would look different on a paid plan.
Image widths are a billing control. Next’s responsive images request roughly eight widths per image by default. Cloudflare bills a transformation per unique combination of source image and settings, so at 300 catalogue images that is 2,400 unique transformations for one format, and a second format doubles it. Against a 5,000 monthly allowance, that is one busy month from costing money. So the loader clamps every request to exactly three buckets — 400, 800 and 1200 — and uses format=auto, which serves AVIF or WebP by content negotiation and counts as one transformation rather than one per format. The comment at the top of the file is the whole point:
/**
* So widths are clamped to exactly three buckets. 300 images x 3 widths = 900
* unique transformations, permanently inside the free tier, and each bucket is
* reused across every product rather than being generated per layout.
*
* `format=auto` serves AVIF/WebP by content negotiation and counts as one
* transformation, not one per format.
*
* Changing ALLOWED_WIDTHS is a billing decision, not a styling one.
*/
The dashboard reads yesterday’s arithmetic. An admin dashboard that sums the whole orders table on every page load is, in the file’s own words, “the one query in this build capable of burning the free tier’s 5 million daily row reads by itself” — because it grows with the shop and gets slower and more expensive precisely as the business succeeds. A nightly cron rebuilds a trailing 45-day window into a daily_sales table, and only today is computed live, because today changes while you are watching it.
The catalogue runs three flat queries, not one JOIN. This is the least obvious of the three. A single JOIN across products, variants and images returns a row per combination and then has to be de-duplicated in code. It is one round trip and it feels tidier. But D1 is metered in rows read, and that JOIN reads far more rows to produce the same answer. Three flat queries assembled in JavaScript read fewer. The same file caps its page size — “an unbounded limit is a free-tier row-read leak” — because an endpoint that accepts ?limit=100000 is a way for anyone to spend the day’s allowance in an afternoon.
Two smaller ones follow the same logic. Uploads are taken as the raw request body rather than as multipart form data, because parsing multipart means scanning the whole payload for boundary markers and the CPU budget is 10 ms; taking the bytes directly makes an upload almost pure I/O. And there is no PDF library for invoices, because the browser already has a renderer that produces a better file than anything worth shipping into a 10 ms budget.
The account has to be the client’s
Cloudflare’s free tier is metered per account. That one fact decides where the store lives.
If Snackly’s shop ran inside our Cloudflare account, their traffic would draw down an allowance shared with every other client in it. Two stores in one account share 100,000 requests a day, 5 GB of D1 and 10 GB of R2 — so a busy Diwali on one throttles the other, and a genuinely good month for one client puts us onto a paid plan on behalf of somebody else’s business.
This is not hypothetical tidiness. When the deployment was being prepared, the account it would otherwise have deployed into was found to already hold another client’s resources. The target account was checked before anything was created: no zones, no D1, nothing belonging to anyone else.
The second reason is better. A store built in the client’s own account makes handover a role grant rather than a migration. There is no exporting a database, no re-pointing DNS, no window where the shop is in two places. If the relationship ends, they change one permission and everything keeps running — which is the difference between a deliverable and a dependency.
The feature that is finished and switched off
The store emails its owner when an order comes in, and sends a low-stock digest overnight. Both are built. Both are tested. Both were deployed. Neither is on.
The sequence is worth walking through, because it is three separate discoveries and only the last one is a decision.
First, the binding deployed and a real order proved the design. A live test order came back:
outcome=ok https://snacklyfoods.in/api/checkout
[error] ['team email failed', 'could not find account config of sending domain']
outcome=ok. The order was written and returned an order number to the customer while the email was failing. That is the guarantee the whole notification path is built around — it runs in waitUntil and swallows every error, because an email must never cost a sale — and here it is demonstrated in production rather than asserted in a unit test.
Second, the obvious fix would have taken down the client’s business email. Onboarding a domain for Cloudflare Email Sending writes MX records for bounce routing, plus SPF, DKIM and DMARC. snacklyfoods.in is not a spare domain: its MX points at Google Workspace, it already carries an SPF include and a DMARC policy, and the address the alerts go to is a live mailbox on it. A domain may carry only one SPF record — a second is a permanent error, and every message the business sends would start failing authentication. The instruction written down an hour earlier was one command away from breaking a client’s mail.
Third, and this is the one that matters: it was never free. Cloudflare Email Sending requires the Workers Paid plan. The line on the pricing page about sends to verified destinations being free describes what happens once you are already paying; it is not a route onto the plan. From the commit:
The pricing page’s line about sends to verified destinations being free and outside the quota describes what happens once you are already paying; it is not a way onto the plan. I had read it as one and said so twice.
That turns a technical step into a commercial one. The scope document tells the client, in writing, twice, that their monthly platform cost is zero. Switching this on starts a bill in their name against that sentence — about ₹450 a month, for a feature nobody had agreed to buy.
So it is off. Not half-built, not forgotten: the binding is commented out in the config with the reason attached, sendTeamEmail returns "unbound", the nightly digest logs its count instead of sending, and no order is ever delayed or failed by an email that cannot send. The client-facing document that previously said “Included” against those emails now says they are built, deployed and not switched on, and explains why.
The alternative was available and slightly humiliating: leave it on, let it fail silently, and let the promise stand on a technicality. What made that unattractive is that it would have worked. Nobody would have noticed for months.
The admin has no login form
There is no password table in this build. No sessions table, no password reset email, no rate limiter on a login endpoint, no bcrypt. Those are all things that can be got wrong, and the way to get them right was to not have them.
Cloudflare Access sits in front of /admin and /api/admin, with a policy naming individual email addresses and a one-time PIN as the identity method — nothing to share, nothing to leak, nothing to reset. That is the login, and on the free plan it covers 50 users against a shop run by two people.
The important half is what happens behind it:
/**
* Access sits in front of /admin and /api/admin at the edge, so an
* unauthenticated browser never reaches this Worker. That is not enough on its
* own: anyone who learns the workers.dev URL can call the origin directly and
* skip Access entirely. Every admin request therefore re-verifies the signed
* assertion Access attaches.
*
* This middleware FAILS CLOSED. If ACCESS_TEAM_DOMAIN or ACCESS_AUD is missing,
* every admin request is denied — a misconfigured deployment must never be an
* open one.
*/
An edge gate protects the path, not the origin. The Worker verifies the RS256 signature against Cloudflare’s JWKS, checks expiry, and matches the audience — without that last check, a valid token for any other application in the same team would be accepted here.
That double verification stopped being theoretical the day the custom domain went live. Access had destinations configured for the old preview host and not the new one, so /admin on snacklyfoods.in returned 200 with no login while every /api/admin/* call came back 401. No data was exposed — the panel’s shell is a static bundle and the Worker refused every request it made — but the shell was publicly readable, and the first report of it was “Razorpay is broken in the admin”, which is exactly what a panel that cannot reach its own API looks like from the outside.
The development bypass is inverted, and it is the detail worth stealing:
// The guard is deliberately the other way round from the obvious one. If any
// real Access configuration is present, the bypass does not merely step aside
// — it refuses the request outright. So a deployment that somehow carries
// DEV_ADMIN_EMAIL is dead rather than open
The obvious implementation ignores the dev variable when real config is present, which fails open under exactly the circumstances you would least want. This one returns 503 instead. A misconfigured deployment is broken and visible rather than working and wrong.
A static export has one sharp edge, and the client found it
The storefront being prerendered is what makes it free. It also means the set of URLs is decided at build time, and the catalogue is not.
Rename a product in the admin panel and the new address 404s while the old one keeps working. The client did exactly this, and reported it as a bug, and was right to. It is worse than the report suggested: the homepage fetches its bestsellers live from the API, so a product added in the panel appears in the listing and then 404s when someone clicks it. “Add, edit and remove products” without a developer is the first line of the scope, and it did not hold.
The mitigation is a 404 page that queries the API and renders the product from live data when the slug exists in the database. That covers the customer who clicked the link. It is honest about what it does not cover: the response is still HTTP 404 and the URL is still absent from the sitemap, so search engines will not index that product until the next rebuild.
The proper fix is a rebuild on catalogue change, which is a deploy hook and a small amount of ceremony. The interesting part is that this is the actual price of the free tier, and it is not the one you would predict. Nobody hit a request cap. What they hit was the consequence of moving rendering to build time to stay under a CPU limit — and that consequence lands on the one workflow the client uses most.
This is also the same shape as six of our own agent’s seventeen tools having no production evidence: three times in this build, an admin API route existed and worked and had no screen calling it — changing a price on an existing product, deleting a pack size, managing categories at all. Each was reported as missing. Each was there. A route with no caller is indistinguishable from a route that does not exist, and only one of those is visible in a test suite.
The canonical I broke, and the guard that replaced the warning
On 2 September I ran a plain npm run build to check that an invoice page compiled. That rewrote the static output without NEXT_PUBLIC_SITE_URL, and then I deployed it. Every canonical tag, og:url and sitemap entry on the live store pointed at the preview host instead of the real domain, and Search Console picked it up.
The deployment runbook had warned about this specific mistake since the image-transformation work. The warning was accurate, it was in the right file, and I had written it.
It did not stop me, so a script does now. npm run deploy refuses to ship a build whose canonical, sitemap or image URLs are not production. The script’s own header says it better than the runbook did: a warning in a document does not stop anybody. This does.
This is the second time this rule has earned its place. The last time we measured whether documentation written for a reader actually gets read, the answer was that structure and provenance pay for themselves and instructions do not — the same finding from the other end. If a rule matters, it belongs in something that can fail, not in something that can be skimmed.
Both hosts now carry a canonical pointing at the real domain, which is how the preview URL stops competing for the same pages without a redirect that would cost a Worker request on every static asset.
What ₹0 actually buys
The numbers, since the point of all this was a bill that does not arrive.
Image transformations took the homepage from 3,746 KB to 233 KB on a phone. That is two steps: re-encoding the worst offenders by hand got it to 1,139 KB — the hero was a 2 MB PNG, a photograph stored losslessly — and transformations with format=auto did the rest, and will do it for every future upload without anyone remembering to. The hero alone drops from 213 KB to 24 KB at the width a phone actually requests.
The Worker starts in 7 ms and serves 239 static assets. There are 313 tests, and they run in workerd against a real D1 rather than a mock, which matters more here than usual: the correctness this design leans on lives in the database. CHECK (stock >= 0) turning an oversell into a rolled-back batch is a property of SQLite, and a mocked D1 would cheerfully pass tests for behaviour the real one rejects. The signature test fires eight simultaneous buyers at one unit of stock and asserts that exactly one order results — which is also why there are no Durable Objects and no Queues in this build. A single conditional UPDATE inside a D1 batch does that job at this volume, and it is two fewer services to explain at handover.
Four things were excluded, and three of them were excluded because they would create a recurring charge: customer-facing transactional email, SMS and WhatsApp Business messaging, and courier API integration. The fourth — server-generated PDF invoices — was excluded on the 10 ms CPU limit rather than on money. The substitute for the first is an on-screen confirmation, order lookup by number and phone, and a click-to-chat link, which costs nothing and is how most Indian D2C support actually happens.
And the honest part. This is not free hosting that scales; it is free hosting that stops. At roughly 6,000 shopping sessions a day the request cap becomes real, and on that day the store returns errors rather than an invoice. For this client, today, that is the right trade — a shop that breaks loudly at a volume they have never seen is better than a shop that quietly bills a card they forgot about. It would be the wrong trade for a business that already has traffic, and the difference is worth saying out loud rather than selling around.
The pattern I would keep from all of this is the smallest one. The site was carrying the words “FSSAI certified” in its hero, its ticker and every product page — a regulatory claim about a business whose licence number we did not have. It now renders only where the number does. The claim and the evidence arrive together, or neither does.
Every figure here is first-party, measured on the deployed store between 14 August and 8 September 2026 across 62 commits. Payload numbers are from the live homepage on a mobile viewport; the Worker startup time and asset count are from the deploy output. Free-tier limits are quoted from Cloudflare’s own pricing documentation as of September 2026 and were re-checked against it while writing — they change, so check before relying on them. Test counts are vitest runs in workerd against a real D1. This is one store, one codebase, one author.
FAQ
Can you run a real ecommerce store on Cloudflare’s free tier?
Yes, with a specific architecture. The storefront must be static so that page views hit Workers Static Assets, which are free and unlimited, leaving the 100,000 daily Worker requests for cart, checkout and admin actions only. Snackly’s store runs on Workers, D1, R2, Image Transformations and Access with ₹0 monthly platform cost, taking real payments. The limits that actually bite are 5,000 image transformations a month and 10 ms of CPU per request — not storage or bandwidth.
What happens when you exceed a Cloudflare free tier limit?
You get an error, not a bill. Cloudflare’s documentation states that exceeding Workers limits means “further operations of that type will fail with an error”, that D1 blocks queries rather than charging, and for image transformations that “you will not be charged for exceeding the limits in the Free plan”. This is the property that makes a free tier safe to hand to a client: the failure mode is downtime, which someone will call you about, rather than a surprise charge on a personal card.
Should a client’s site live in my Cloudflare account or theirs?
Theirs, and the reason is that the free tier is metered per account rather than per project. Two clients in one account share 100,000 Worker requests a day, 5 GB of D1 and 10 GB of R2, so one client’s good month throttles another’s site and can push you onto a paid plan on their behalf. Building in the client’s account also makes handover a permission change rather than a migration of a live shop.
Why clamp Next.js image widths on Cloudflare?
Because Cloudflare bills a transformation per unique combination of source image and settings, and Next’s responsive defaults request about eight widths per image. At 300 catalogue images that is 2,400 unique transformations for one format against a 5,000 monthly free allowance. Clamping to three width buckets and using format=auto — which counts as one transformation rather than one per format — brings it to 900. The images render identically; the difference is entirely in what gets generated.
Do I need a login system for an admin panel on Cloudflare?
Not if Cloudflare Access will do. Access gates the route at the edge with an identity provider — one-time PIN needs no password at all — and is free for up to 50 users, which removes the password table, session store, reset flow and login rate limiter from your codebase entirely. Verify the signed assertion in your Worker as well as at the edge, because anyone who learns the origin URL can call it directly and skip the gate.
What is the catch with a Next.js static export on Workers?
The URL set is fixed at build time and your catalogue is not. Rename a product in the admin panel and its new URL returns 404 until the next deploy, while the old one keeps working; a newly added product can appear in a live-fetched listing and 404 when clicked. You can soften this by rendering from the API on the 404 page, but the response is still a 404 and the URL is still missing from the sitemap. If non-technical users add products often, budget for a rebuild-on-change hook.
Is Cloudflare D1 free tier enough for an online shop?
For a small one, comfortably — but budget in rows read rather than in queries. The free plan allows 5 million rows read and 100,000 written per day, so the thing to avoid is any query whose cost grows with the business: a dashboard summing the whole orders table on every load is the classic example, and it gets more expensive exactly as the shop succeeds. Precompute those on a cron, prefer several flat queries to one wide JOIN, and never leave a page-size parameter unbounded.
Sources
- Workers pricing and limits — Cloudflare, on the 100,000 daily requests and 10 ms CPU
- Static assets — Cloudflare, on asset requests being free and unlimited
- D1 pricing — Cloudflare, on rows read as the billing unit
- R2 pricing — Cloudflare, including zero egress
- Images pricing — Cloudflare, on the 5,000 free transformations and the
9422error - Zero Trust plans — Cloudflare, on the 50 free Access seats
- Validating JSON web tokens — Cloudflare Access documentation
- Email Routing and Sending — Cloudflare, the binding this build ships disabled
- Static exports — Next.js
- Hono — the router running on the Worker
- Vitest pool for Workers — Cloudflare, running tests in workerd against a real D1
CloudflareEcommerceWorkersClient WorkEngineering
Read next
- Eight bots played all 500 levels. They were measuring the wrong game.We built eight bots to play a 500-level arcade game and measure how hard each level is. They played it 720 pixels wide. Phones are 412. Levels do not scale, so the shipped game was roughly twice as hard as the game being measured: 172 of 500 levels in band, not the 421 we were reporting.
- Our agent passed every red team probe. That was the problem.We pointed a generated red team at our agent and it passed everything. Then we counted the replies: 99 of 114 were byte-identical. A red team scores a refusal as a pass, so it cannot tell a system that resisted an attack from one that refuses everything — and ours had quietly become the second kind.
- Six of our agent's seventeen tools had never run.Six of seventeen agent tools had never once run in production, including both of the ones that unlock a contact and charge for it. This is the harness that finally tested them — a real model in a completely faked world, 41 scenarios, 123 runs, $3.06 — and the cost blind spot it uncovered on the way.