This guide walks through deploying your own copy on Cloudflare. Everything here is free-tier-friendly; you only pay if traffic or AI chat usage grows.
| Service | Purpose | Free tier ok? |
|---|---|---|
| Cloudflare Workers | menu-backend API + menu-chat AI assistant |
Yes (100k req/day) |
| Cloudflare Pages | Next.js frontend (menu) |
Yes |
| Cloudflare D1 | SQLite database (settings, menus, entries, analytics) | Yes (5 GB) |
| Cloudflare R2 | Image uploads + catalog snapshot cache | Yes (10 GB) |
| Cloudflare KV | Chat menu cache | Yes |
| Cloudflare Access | Admin login (Google / GitHub / email OTP / SAML / OIDC) | Yes (≤50 users) |
| OpenAI API | Diner chat + admin translation helper | Paid (optional) |
Heads up: the AI chat is optional. If you do not set
OPENAI_API_KEY on the chat worker, you can disable the chat from the
admin UI (Settings → Chat AI) — the rest of the app runs fine.
node >= 22
npm >= 10
git
npm i -g wrangler # optional; the local devDep is also fineYou'll also need:
- A Cloudflare account (free) with Zero Trust enabled (free up to 50 users).
- An admin email address (you'll list it in
ADMIN_EMAILS). Cloudflare Access will accept it via Google / email OTP / GitHub / your IdP of choice. - (Optional) An OpenAI API key with a small monthly cap.
git clone https://github.com/vekexasia/tony-menu.git
cd tony-menu
npm ci
cd web/workers/chat && npm ci && cd -npx wrangler logincd backend
npx wrangler d1 create menu-dbCopy the returned database_id.
cd ../web/workers/chat
npx wrangler kv namespace create MENU_CACHECopy the returned id.
cd ../../../backend
npx wrangler r2 bucket create menu-publicEnable public access on the bucket in the Cloudflare dashboard and copy the
pub-XXXX.r2.dev URL (or attach a custom domain). You will store both the
public URL and the bucket name in .tony-menu.local.json so config:generate
can emit the PUBLIC_MENU_BUCKET binding automatically.
Run the initializer from the repo root:
cd ..
npm run initializeIt creates .tony-menu.local.json, which is the source of truth for local setup and is gitignored because it may contain secrets. It then generates the runtime files required by Next.js and Wrangler:
backend/wrangler.tomlbackend/.dev.varsweb/.env.localweb/workers/chat/wrangler.tomlweb/workers/chat/.dev.vars
Do not edit the generated files directly. Edit .tony-menu.local.json, then regenerate:
npm run config:generateThe script asks for your D1/KV IDs, URLs, R2 bucket name/public URL, Cloudflare Access values, admin emails, and chat provider. If you do not have the IDs yet, accept the placeholders, create the resources above, update .tony-menu.local.json, then run npm run config:generate.
Admin auth is handled by Cloudflare Access — no Firebase, no password. Access
sits in front of /admin/* and the backend Worker, redirects unauthenticated
visitors to a Cloudflare-hosted login page (Google, GitHub, email OTP, SAML,
OIDC — your choice), and forwards authenticated requests with a verifiable
Cf-Access-Jwt-Assertion header.
In Cloudflare dashboard → Zero Trust → Settings, set your team domain
(e.g. yourteam.cloudflareaccess.com) if you haven't already. The free tier
covers up to 50 users — way more than enough for a single restaurant admin.
Then create two Access applications under Zero Trust → Access → Applications:
- Pages frontend — Application type "Self-hosted", domain
your-pages-project.pages.dev(or your custom domain), path/admin/*. Add an Access policy: e.g. "Emails ending with @yourcompany.com" or "Emails in: you@example.com, partner@example.com". - Backend Worker — Application type "Self-hosted", domain
<your-backend-worker>.workers.dev(or custom domain), path/admin/*. Same policy as above.
Each app's Overview tab shows an AUD tag (a hex string). Copy it into:
backend/wrangler.toml→ACCESS_AUD- Set
ACCESS_TEAM_DOMAIN = https://<your-team>.cloudflareaccess.comin the same file
Add the admin email(s) to backend/wrangler.toml → ADMIN_EMAILS.
The backend verifies the JWT against <team-domain>/cdn-cgi/access/certs —
fully self-contained, no service accounts.
npm run initialize creates web/.env.local with:
NEXT_PUBLIC_API_URL— your backend Worker URL, orhttp://localhost:8787for devNEXT_PUBLIC_CHAT_WORKER_URL— your chat Worker URL, orhttp://localhost:8788NEXT_PUBLIC_DEFAULT_LOCALE— default UI language (en,it,de, …)
The frontend has no auth env vars — Cloudflare Access manages the login flow entirely.
npm run initialize generates local secrets in .dev.vars for development.
For production, set secrets in Cloudflare before deploy:
# Backend (only needed if you use the admin translation helper)
cd backend
npx wrangler secret put OPENAI_API_KEY
# Chat worker
cd ../web/workers/chat
npx wrangler secret put OPENAI_API_KEY # if LLM_PROVIDER=openai
npx wrangler secret put ANTHROPIC_API_KEY # if LLM_PROVIDER=anthropic
npx wrangler secret put CHAT_SESSION_SECRET
npx wrangler secret put REFRESH_SECRETcd backend
# Local D1 (created on first dev run)
npx wrangler d1 migrations apply menu-db --local
# Remote D1 (production)
npx wrangler d1 migrations apply menu-db --remoteThe initial migration creates all tables and seeds a singleton settings row
with name "My Restaurant". You'll edit that from /admin later.
Three terminals:
# 1. Backend API
cd backend && npm run dev # → http://localhost:8787
# 2. Chat worker
cd web/workers/chat && npm run dev # → http://localhost:8788
# 3. Frontend
cd web && npm run dev # → http://localhost:3000Open http://localhost:3000/admin. In local dev, Cloudflare Access isn't
in the loop — requireAuth returns 503 because ACCESS_TEAM_DOMAIN /
ACCESS_AUD aren't set. To bypass during dev, either point at the deployed
backend (NEXT_PUBLIC_API_URL in .env.local) or use the Playwright admin
bypass that injects a fake user into window.__playwright_admin__ (see
web/src/app/admin/AdminContent.tsx).
# Backend
cd backend && npm run deploy
# Chat worker
cd ../web/workers/chat && npm run deploy
# Frontend (Cloudflare Pages)
cd ../.. && CF_PAGES_PROJECT=menu npm run deploy:cfFor Pages, set the same NEXT_PUBLIC_* vars in the Pages dashboard
(Settings → Environment variables) so production builds pick them up — or,
if you build locally, put them in web/.env.production.local (gitignored).
Point your domain at the Pages project:
- Cloudflare Pages → your project → Custom domains → add your domain.
- Add the same domain to
ALLOWED_ORIGINSinbackend/wrangler.tomlandweb/workers/chat/wrangler.toml. Re-deploy both workers. - In
web/.env.production.local, setNEXT_PUBLIC_API_URLandNEXT_PUBLIC_CHAT_WORKER_URLto your worker URLs and re-build / re-deploy the frontend. - Update the Cloudflare Access apps (Zero Trust → Access) to cover the new custom domain too — add it to the application's hostnames list.
That's it.
- Cloudflare free tier covers a small restaurant comfortably.
- OpenAI usage scales with chat traffic. Set a hard monthly cap.
- If you do not need AI chat, skip step 6's chat-worker secrets and disable
ai_chat_enabledfrom/admin?s=settings-chat-ai.
wrangler.toml not found— copy fromwrangler.toml.example.- 403 on
/admin— your email isn't inADMIN_EMAILSon the backend worker. Add it andnpx wrangler deploy. - 401 / 503 on
/admin— Cloudflare Access isn't in front of the route, orACCESS_TEAM_DOMAIN/ACCESS_AUDaren't set on the backend Worker. Re-check the Access app config in Zero Trust and the wrangler vars. - CORS errors — your frontend origin isn't in
ALLOWED_ORIGINS. Update both backend and chat workerwrangler.toml, redeploy, and double-check Pages build-time env vars match what the workers know about. - Empty menu /
Menu not published— go to/admin?s=settings-publishingand toggle the menu to Published. - Chat returns 503 / 403 —
OPENAI_API_KEYnot set on the chat worker, orai_chat_enabledis false. Both are configurable in the admin UI.