Cloudflare Infrastructure Setup¶
This document explains the Cloudflare configuration for kwilo.ai and why it was implemented.
Why Cloudflare?¶
The Problem¶
VidyaNet's backend runs on Google Cloud Run in the asia-south1 (Mumbai) region. We wanted custom domains like api.kwilo.ai instead of the default Cloud Run URL (vidyanet-backend-xxxxx.asia-south1.run.app).
However, Cloud Run's domain mapping feature is NOT available in asia-south1.
Available regions for Cloud Run domain mapping (as of Feb 2026): - us-central1, us-east1, us-west1 - europe-west1 - asia-east1, asia-northeast1
Since we need asia-south1 for low latency to Indian users, we needed an alternative solution.
The Solution: Cloudflare Workers¶
We use Cloudflare Workers as a reverse proxy to route requests from custom domains to Cloud Run:
Benefits: - Custom domains work with any Cloud Run region - Edge caching capabilities - DDoS protection included - SSL/TLS termination at edge - Global CDN for static assets
Architecture¶
┌─────────────────────────────────────────────────────────────────────┐
│ Cloudflare Edge │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ kwilo.ai │ │staging.kwilo │ │ img.kwilo │ │
│ │ www.kwilo │ │ .ai │ │ .ai │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ CF Pages │ │ CF Pages │ │ imgproxy │ │
│ │ kwilo-site │ │ staging br. │ │ (Cloud Run) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ api.kwilo.ai │ │api-staging │ │
│ │ │ │ .kwilo.ai │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Cloudflare │ │ Cloudflare │ │
│ │ Worker │ │ Worker │ │
│ │ (Production) │ │ (Staging) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
└─────────┼───────────────────┼────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Cloud Run │ │ Cloud Run │
│ Production │ │ Staging │
│ (asia-south1) │ │ (asia-south1) │
└──────────────────┘ └──────────────────┘
Domain Configuration¶
DNS Records (Cloudflare)¶
| Type | Name | Content | Proxy | Purpose |
|---|---|---|---|---|
| CNAME | @ | kwilo-site.pages.dev | Proxied | CF Pages (production branch of kwilo-site) |
| CNAME | www | kwilo.ai | Proxied | Redirect to root |
| CNAME | staging | staging.kwilo-site.pages.dev | Proxied | CF Pages (staging branch of kwilo-site) |
| CNAME | app | kwilo-web.pages.dev | Proxied | CF Pages (production branch of kwilo-web) |
| CNAME | app-staging | staging.kwilo-web.pages.dev | Proxied | CF Pages (staging branch of kwilo-web) |
| CNAME | img | imgproxy-xxx.asia-south1.run.app | Proxied | Image optimization |
| (Worker) | api | - | - | Managed by Worker |
| (Worker) | api-staging | - | - | Managed by Worker |
Notes:
- api and api-staging DNS records are managed automatically by Cloudflare when you add custom domains to Workers.
- All four hostnames (kwilo.ai, app.kwilo.ai, staging.kwilo.ai, app-staging.kwilo.ai) now serve from CF Pages. Vercel is no longer in the path.
SSL/TLS Configuration¶
- Mode: Full (strict)
- Minimum TLS Version: 1.2
- Always Use HTTPS: Enabled
Cloudflare Workers¶
Production API Worker (kwilo-api-proxy)¶
Purpose: Proxy requests from api.kwilo.ai to Cloud Run production backend.
Worker URL: https://kwilo-api-proxy.kwilo.workers.dev
Custom Domain: api.kwilo.ai
Target: https://vidyanet-backend-12843608316.asia-south1.run.app
Code:
const BACKEND_URL = "https://vidyanet-backend-12843608316.asia-south1.run.app";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const targetUrl = BACKEND_URL + url.pathname + url.search;
const modifiedRequest = new Request(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'follow'
});
const response = await fetch(modifiedRequest);
const modifiedResponse = new Response(response.body, response);
// CORS headers
modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');
modifiedResponse.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
modifiedResponse.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return modifiedResponse;
}
}
Staging API Worker (kwilo-api-staging-proxy)¶
Purpose: Proxy requests from api-staging.kwilo.ai to Cloud Run staging backend.
Worker URL: https://kwilo-api-staging-proxy.kwilo.workers.dev
Custom Domain: api-staging.kwilo.ai
Target: https://vidyanet-backend-staging-n6lvbgtcta-el.a.run.app
Cloudflare Pages (Frontend Hosting)¶
Both frontends ship to Cloudflare Pages. Staging cut over 2026-04-22; production cutover completed 2026-04-27.
Projects¶
| Project | Repo Path | Production Branch | Custom Domains | .pages.dev Subdomain |
|---|---|---|---|---|
kwilo-site |
apps/site |
main |
kwilo.ai, staging.kwilo.ai |
kwilo-site.pages.dev |
kwilo-web |
apps/web |
main |
app.kwilo.ai, app-staging.kwilo.ai |
kwilo-web.pages.dev |
Both projects live on Kantharaju's Cloudflare account (33920a27a0e431ae02f38b4795969fb0) — same account as the kwilo.ai zone.
How deploys work¶
No CF-side Git integration. All deploys are driven from GitHub Actions using npx wrangler@3 pages deploy:
apps/site→.github/workflows/site-cf-pages.ymlapps/web→.github/workflows/web-cf-pages.yml
Triggers on push to main / staging branches with path filters for the respective app + shared packages. Env vars (VITE_API_URL, VITE_SITE_URL, VITE_ENVIRONMENT) are injected per-branch in the workflow.
Branch-pinned custom domains¶
staging.kwilo.ai and app-staging.kwilo.ai are CNAMEd to staging.<project>.pages.dev (not the project root). That's CF Pages' "custom domain to branch" feature — the staging hostnames always serve the latest staging branch deploy, regardless of what's on main. See CF docs.
Required GitHub secrets¶
CLOUDFLARE_API_TOKEN— custom token, scoped tokwilo.aizone + Kantharaju's account, with Pages:Edit + DNS:Edit. 1-year TTL.CLOUDFLARE_ACCOUNT_ID—33920a27a0e431ae02f38b4795969fb0
Config files¶
apps/site/public/_headers,apps/site/public/_redirects— CF Pages headers/redirects. Files inpublic/are copied into thedist/root at build time.apps/web/public/_headers,apps/web/public/_redirects— same.
Cloudflare Access (Zero Trust)¶
Staging hostnames and all *.pages.dev preview URLs are gated by Cloudflare Access. Only emails ending in @kwilo.ai can authenticate.
Zero Trust team¶
- Team name:
kwilo - Auth domain:
kwilo.cloudflareaccess.com - Account: Kantharaju (
33920a27a0e431ae02f38b4795969fb0) - Plan: Free (up to 50 users)
Identity provider¶
- Type: OneTimePin (email OTP)
- IdP ID:
d3146c02-7ed9-4b56-8416-b7190cb127a5 - Flow: user enters email → CF sends 6-digit code → user enters code → policy evaluates → allowed iff email ends in
@kwilo.ai
Google Workspace SSO can be added as a second IdP when tighter "current-employee-only" gating is needed. Until then, OneTimePin is zero-setup and works for anyone who can receive email at a @kwilo.ai inbox.
Access applications¶
11 apps total, split into two groups.
Gating apps — allow policy: include: [{ email_domain: { domain: "kwilo.ai" } }] with allowed_idps: [<OneTimePin>], 24h session.
| Hostname / Path | Access Application | Purpose |
|---|---|---|
staging.kwilo.ai |
Kwilo Staging Site | apps/site on staging (CF Pages) — marketing HTML |
app-staging.kwilo.ai |
Kwilo Staging App | apps/web on staging (CF Pages) — HTML routes only; /api/* and /assets/* are bypassed (see below) |
api-staging.kwilo.ai |
Kwilo Staging API | apps/backend on staging (Cloud Run via Worker proxy). Also has a service-token allow policy for the Pages Function proxy |
*.kwilo-site.pages.dev |
Kwilo Site — pages.dev URLs | All preview URLs for kwilo-site project |
*.kwilo-web.pages.dev |
Kwilo Web — pages.dev URLs | All preview URLs for kwilo-web project |
kwilo-site.pages.dev |
Kwilo Site — base pages.dev | Bare production alias |
kwilo-web.pages.dev |
Kwilo Web — base pages.dev | Bare production alias |
Bypass apps on app-staging.kwilo.ai — decision: "bypass" policies with include: [{ everyone: {} }]. More-specific paths take precedence over the wildcard Kwilo Staging App gate, so these paths skip CF Access entirely.
| Path | Access Application | Reason for bypass |
|---|---|---|
app-staging.kwilo.ai/api/* |
Kwilo Staging App — /api bypass | XHR calls from the SPA; Pages Function proxy handles upstream service-token auth to api-staging.kwilo.ai. Gating this path would break login (CF Access 302 → cross-origin login HTML → CORS blocks XHR). |
app-staging.kwilo.ai/assets/* |
Kwilo Staging App — /assets bypass | Static JS chunks, CSS, images. Gating breaks lazy-loaded React chunks (same CORS-on-XHR problem for import() calls). Chunk filenames are content-hashed; revealing them doesn't compromise anything. |
app-staging.kwilo.ai/favicon.svg |
Kwilo Staging App — /favicon.svg bypass | Static icon; public by nature. |
app-staging.kwilo.ai/manifest.json |
Kwilo Staging App — /manifest.json bypass | PWA manifest; static metadata. |
Service tokens¶
Both tokens are referenced by the same Access policy on api-staging.kwilo.ai — Service: kwilo-web staging Pages proxy + local dev (policy id 488af562-…, non_identity decision). Adding a new dev token = appending another service_token rule to that policy's include list.
kwilo-web-staging-to-api— bound to the Pages Function proxy (apps/web/functions/api/[[path]].ts). Used server-side only, injected by the Function asCF-Access-Client-Id/CF-Access-Client-Secretheaders on outbound calls toapi-staging.kwilo.ai. Client ID + secret stored as encrypted Pages env vars on thekwilo-webproject (preview + production scopes). 1-year TTL; rotate viaPOST /access/service_tokens/{id}/rotate. Created 2026-04-22, expires 2027-04-22.kwilo-localdev-to-staging-api— used by individual developers runningpnpm --filter @kwilo/web devagainst the staging backend. The Vite proxy inapps/web/vite.config.tsinjects the same headers whenCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRETare set inapps/web/.env.local. Separate from the prod token so developer rotation never affects production. Created 2026-04-27, expires 2027-04-27.
Local dev → staging backend (the recipe)¶
Two npm scripts, two mental models:
| Command | Backend | Use when |
|---|---|---|
pnpm --filter @kwilo/web dev |
localhost:8000 (Docker) |
Daily local dev |
pnpm --filter @kwilo/web dev:staging |
https://api-staging.kwilo.ai |
Reproducing staging-data bugs, or front-end work against the live backend |
The dev:staging script sets VITE_PROXY_TARGET inline; vite.config.ts reads CF Access credentials from .env.local and injects them as CF-Access-Client-Id / CF-Access-Client-Secret headers on every upstream request (mirrors the production Pages Function).
One-time setup — in apps/web/.env.local (gitignored):
VITE_API_URL=/api/v1
CF_ACCESS_CLIENT_ID=<from kwilo-localdev-to-staging-api>
CF_ACCESS_CLIENT_SECRET=<same token>
Do NOT add VITE_PROXY_TARGET to .env.local — leaving it unset preserves pnpm dev's "local Docker" semantic. The dev:staging script overrides it via shell env.
Verify after pnpm dev:staging:
If the response is HTML or Authentication required comes back as the raw HTML body instead of FastAPI JSON, the service token isn't being injected — check .env.local exists and Vite was restarted after editing it (Vite only reads env files at startup).
The SPA + CF Access gotcha (why the bypass apps exist)¶
CF Access by default gates every path under an application's hostname. This works for server-rendered apps but breaks single-page apps with lazy-loaded routes and XHR APIs. The specific failure mode:
- User authenticates via OTP →
CF_Authorizationcookie set forapp-staging.kwilo.ai - SPA makes a fetch to
/api/v1/auth/meor a dynamicimport()for a route chunk - At some point the cookie's audience/policy hash becomes stale (session rotation, policy edits, idle timeout)
- CF Access responds to the XHR with
302 → kwilo.cloudflareaccess.com/cdn-cgi/access/login/... - Browser follows the cross-origin redirect
- Final response (CF Access login page HTML) has no
Access-Control-Allow-Originheader matchingapp-staging.kwilo.ai - Browser CORS-blocks reading the response →
fetch()rejects with opaque "Network Error" or dynamicimport()throwsFailed to fetch dynamically imported module - SPA can't recover automatically — cross-origin redirect-to-HTML is an XHR dead end
The fix is path-scoped bypass apps: keep the OTP gate on HTML routes (where the user starts their session via browser navigation — which CAN follow cross-origin redirects), bypass it on all XHR and static paths. Upstream auth (service token on api-staging.kwilo.ai) keeps the API gated at its own edge.
Managing access¶
To add a new user: nothing to configure — anyone with a @kwilo.ai email can request OTP and log in. CF Access logs every login attempt (allowed or denied); see Zero Trust → Logs → Access.
To revoke a specific user: add an explicit exclude rule in each app's policy with {email: {email: "person@kwilo.ai"}}. Or change the IdP to Google Workspace and deprovision there.
To temporarily bypass an app (for debugging): change decision to bypass on the app's policy. Re-enable by changing back to allow. Never delete the application — that removes the gate entirely.
Known gaps¶
- No zone-level
X-Robots-Tag: noindexTransform Rule yet. CF Access already blocks Googlebot (302 to login page, which it can't pass), but a zone-level header is belt-and-braces in case Access is ever misconfigured. Requires dashboard access — not provisionable via CF MCP OAuth scope.
Emergency: fully disable Access on a single host¶
- Zero Trust → Access → Applications → find the app
- Policies tab → toggle the Allow policy off (don't delete — you'll lose the config)
- Traffic now bypasses Access for that host until you toggle back on
Cloudflare Account¶
- Account: kantharaju@kwilo.ai
- Plan: Free tier
- Zone: kwilo.ai
Managing the Setup¶
Update Worker Code¶
-
Navigate to worker directory:
-
Edit
worker.js -
Deploy:
Add New Custom Domain to Worker¶
- Go to Cloudflare Dashboard → Workers & Pages
- Select the worker
- Settings → Domains & Routes → Add Custom Domain
View Worker Logs¶
Update DNS Records¶
- Cloudflare Dashboard → DNS → Records
- Add/Edit/Delete records as needed
Cost¶
| Service | Cost |
|---|---|
| Cloudflare Free Plan | $0/month |
| Workers (Free tier) | 100,000 requests/day free |
| imgproxy (Cloud Run) | ~$10-15/month |
Troubleshooting¶
API returns 522 (Connection timed out)¶
- Check if Cloud Run service is running
- Verify the backend URL in worker code is correct
API returns 525 (SSL handshake failed)¶
- Ensure Cloudflare SSL mode is "Full (strict)"
- Cloud Run has valid SSL certificate (automatic)
CORS errors in browser¶
- Worker adds CORS headers, but check if backend also needs CORS config
- Backend CORS origins should include
https://kwilo.ai
Worker not routing requests¶
- Verify custom domain is added to worker
- Check DNS record exists and is proxied (orange cloud)
SPA fetch() fails with "Network Error" on app-staging¶
See The SPA + CF Access gotcha. Most likely cause: the path you're fetching isn't covered by a bypass app, and the user's CF_Authorization cookie has lapsed. Check CF Access audit logs for recent allowed: false events on app-staging.kwilo.ai. If the path should be public (static asset, API), create a new bypass app for that path pattern.
Dynamic import() fails with "Failed to fetch dynamically imported module"¶
Two causes, both common:
1. Stale tab after deploy. Open tab's HTML references chunk hashes that don't exist in the latest deploy (Vite rehashes on every build). Hard-refresh fixes. Long-term: apps/web/src/routes/lazy-pages.ts wraps lazy() with an auto-reload on failure.
2. CF Access gating /assets/*. Same symptom as above, different cause — browser couldn't follow the cross-origin login redirect that CF Access threw for the chunk request. Check that the /assets/* bypass app exists on the affected hostname.
Login returns 401 on app-staging but service-token curl works¶
Verify the request payload in DevTools → Network → the failed POST → Payload / Raw tab. Compare bytes to what your service-token curl sends. Common causes: password manager truncation, trailing whitespace, trimmed characters. DevTools "Headers" view can hide these; "Raw" is ground truth.
"Access denied" page for a user who should have access¶
User's CF_Authorization cookie has stale aud from a deleted or modified Access app. Clear cookies for app-staging.kwilo.ai and kwilo.cloudflareaccess.com, reauthenticate fresh. Never delete+recreate Access apps on a live domain without warning users; it invalidates every active session.
Legacy Domains¶
vidyanet.ai and staging.vidyanet.ai are still on the original DNS pointing at the old hosting. They will be 301-redirected to the matching kwilo.ai hostnames once we're ready to retire the legacy zone — tracked separately from this doc.
Related Documentation¶
- Media Infrastructure Plan
- CI/CD Workflow
- Backend Config - CORS origins
Changelog¶
| Date | Change |
|---|---|
| 2026-02-01 | Initial Cloudflare setup with kwilo.ai domain |
| 2026-02-01 | Deployed production and staging API workers |
| 2026-02-01 | Configured DNS records and SSL |
| 2026-04-22 | Enabled CF Access (Zero Trust) — team kwilo, OneTimePin IdP, @kwilo.ai email-domain allow. 7 Access apps covering 3 custom staging hostnames + all *.pages.dev URLs. |
| 2026-04-22 | Created CF Pages projects kwilo-site and kwilo-web. Cut over staging DNS from Vercel → CF Pages (branch-pinned to staging). |
| 2026-04-22 | Disabled Vercel auto-deploy for main + staging branches via vercel.json (PR #605). Vercel projects retained as rollback until prod soak passes. |
| 2026-04-22 | Added Pages Function proxy at apps/web/functions/api/[[path]].ts + service token kwilo-web-staging-to-api + encrypted Pages env vars. Browser → same-origin /api/* → Function injects service token server-side → upstream api-staging.kwilo.ai. Fixes CORS-on-XHR for the login flow. (PRs #607, #608, #609, #611) |
| 2026-04-22 | Added 4 path-scoped bypass apps on app-staging.kwilo.ai: /api/*, /assets/*, /favicon.svg, /manifest.json. HTML routes still OTP-gated; SPA XHRs and static assets skip CF Access (fixes "Failed to fetch dynamically imported module" + mid-session login failures). |
| 2026-04-27 | Created service token kwilo-localdev-to-staging-api for local dev → staging API access. Added to existing Service: policy on Kwilo Staging API (renamed Service: kwilo-web staging Pages proxy + local dev). Vite proxy in apps/web/vite.config.ts now injects CF Access headers when CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET are set in apps/web/.env.local. Also stripped CF Access cookies (CF_Authorization, CF_AppSession_*) from Pages Function upstream requests so the service token is the sole identity at the API gate (cleans up audit log inflation). Added /api/v1/health alongside existing /health on the backend. |
| 2026-04-27 | Cut over production frontends from Vercel to CF Pages. kwilo.ai and app.kwilo.ai DNS now CNAME to kwilo-site.pages.dev / kwilo-web.pages.dev. Deleted vercel.json from both apps, removed deploy-frontend-production job from .github/workflows/ci-cd.yml, and stripped *.vercel.app from backend CORS allowlist + preview regex (apps/backend/src/core/config.py, apps/backend/src/main.py). Vercel projects + GitHub App + VERCEL_* GitHub Actions secrets to be removed by hand outside this repo. |