Skip to content
mroot.co
← All writing
Case StudiesJul 3, 2026 · 3 min read

A full dealership stack that costs $0/month to run on Cloudflare

A storefront, staff back office, and AI-assisted tools for a car dealership — running on Cloudflare's free tier and hardened for public traffic. What that setup actually takes.

Project: Navarro Coopen →
45
API endpoints
101
integration tests
$0
monthly infra

Navarro Co is a used-car dealership site: a public storefront, a "sell us your car" intake flow, and a staff back office for managing inventory and enquiries. It runs entirely on Cloudflare's free tier — one Worker, one deploy, no monthly hosting bill — and it's built to be safe to expose on the open internet, not just a demo. That combination is the whole point of this write-up: a small business doesn't need a five-figure stack to get a real, secure web presence.

Architecture

One wrangler deploy ships everything. The Worker (Hono) serves the API under /api and falls through to the static React 19 SPA for everything else. D1 (SQLite) is the single source of truth for inventory, submissions, sales, and enquiries; R2 holds car and submission photos. There are three surfaces in one app: the storefront at /, an admin console at /admin, and a mobile-app mock at /app rendered inside an iOS frame.

The export keeps Hono's methods intact instead of wrapping them, which is what lets the integration tests call the Worker directly:

worker/index.ts
app.route('/api', api);app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404));app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw)); // Object.assign (not a `{ fetch, scheduled }` wrapper) so `app` keeps its Hono// methods — notably `.request()`, which the test suite calls directly.export default Object.assign(app, {  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {    ctx.waitUntil(generateBriefing(env).then(() => undefined));  },});

That scheduled handler is a daily cron that regenerates the AI business briefing, so the admin dashboard always reads from cache and never blocks on a live model call.

AI on a budget

Workers AI shows up in three places: one-line appraisal notes on sell submissions, enquiry triage (intent, sentiment, suggested reply), and the daily briefing. The free-tier Neuron budget shaped all three: results are cached in D1, the briefing regenerates once a day on the cron, and the manual "Regenerate" button is owner-gated behind a rate limiter.

The valuation itself is deliberately not AI. The buy range is deterministic math off the pricing catalog — the model only narrates:

worker/insights.ts
let buyRangeLow: number | null = null;let buyRangeHigh: number | null = null;let flag: ValuationFlag = 'no_catalog_match';if (msrp && tier) {  const [lowPct, highPct] = BUY_RANGE[tier] ?? BUY_RANGE.C;  buyRangeLow = Math.round(msrp * lowPct);  buyRangeHigh = Math.round(msrp * highPct);  flag = sub.price < buyRangeLow ? 'below_range'       : sub.price > buyRangeHigh ? 'above_range' : 'in_range';}

Numbers a staff member acts on shouldn't come out of a language model. Where no sensible fallback exists — enquiry triage — the endpoint returns a clean 503 instead of pretending.

Hardening for public launch

The hardest part wasn't features — it was making a hobby-scale project safe to expose on the open internet:

  • Per-account staff logins with HMAC-signed session cookies. No shared passcode.
  • Cloudflare's native rate limiting on login and public form endpoints, emulated as no-ops in local dev and tests.
  • Turnstile on the sell and enquiry forms, verified server-side, and inert until both keys are configured so tests don't need a real challenge.
  • Length caps on every public and admin text field, whitelisted image keys, 3 MB upload cap, and secureHeaders for the usual response headers.
  • An append-only audit log of every admin action.

All of it is covered by 101 integration tests running against the real Worker in the Cloudflare Vitest pool — the same code path production runs, local D1 and R2 included. This is the same checklist I run through on every client build — see how I approach security-minded development if you're evaluating a site of your own. If you want a look at how I handle a bug like this after launch rather than before it, Remediating a Legacy SQL Injection, Safely covers that side.

What I'd improve

  • The frontend has no test coverage to speak of; the 101 tests are all API-side.
  • Photos are served straight through the Worker from R2 with no resizing pipeline — fine at this scale, wasteful beyond it.
  • The admin console's client state grew organically and could use a proper store now that it spans eight views.
Marc Delacruz — full-stack, security-minded.Get in touch →