Skip to content

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:

Browser → api.kwilo.ai → Cloudflare Worker → Cloud Run Backend

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.yml
  • apps/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 to kwilo.ai zone + Kantharaju's account, with Pages:Edit + DNS:Edit. 1-year TTL.
  • CLOUDFLARE_ACCOUNT_ID33920a27a0e431ae02f38b4795969fb0

Config files

  • apps/site/public/_headers, apps/site/public/_redirects — CF Pages headers/redirects. Files in public/ are copied into the dist/ 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.aidecision: "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.aiService: 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 as CF-Access-Client-Id / CF-Access-Client-Secret headers on outbound calls to api-staging.kwilo.ai. Client ID + secret stored as encrypted Pages env vars on the kwilo-web project (preview + production scopes). 1-year TTL; rotate via POST /access/service_tokens/{id}/rotate. Created 2026-04-22, expires 2027-04-22.
  • kwilo-localdev-to-staging-api — used by individual developers running pnpm --filter @kwilo/web dev against the staging backend. The Vite proxy in apps/web/vite.config.ts injects the same headers when CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET are set in apps/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:

curl -s http://localhost:5173/api/v1/health
# {"status":"healthy","environment":"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:

  1. User authenticates via OTP → CF_Authorization cookie set for app-staging.kwilo.ai
  2. SPA makes a fetch to /api/v1/auth/me or a dynamic import() for a route chunk
  3. At some point the cookie's audience/policy hash becomes stale (session rotation, policy edits, idle timeout)
  4. CF Access responds to the XHR with 302 → kwilo.cloudflareaccess.com/cdn-cgi/access/login/...
  5. Browser follows the cross-origin redirect
  6. Final response (CF Access login page HTML) has no Access-Control-Allow-Origin header matching app-staging.kwilo.ai
  7. Browser CORS-blocks reading the response → fetch() rejects with opaque "Network Error" or dynamic import() throws Failed to fetch dynamically imported module
  8. 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: noindex Transform 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

  1. Zero Trust → Access → Applications → find the app
  2. Policies tab → toggle the Allow policy off (don't delete — you'll lose the config)
  3. 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

  1. Navigate to worker directory:

    cd /tmp/kwilo-api-worker  # or kwilo-api-staging-worker
    

  2. Edit worker.js

  3. Deploy:

    npx wrangler deploy
    

Add New Custom Domain to Worker

  1. Go to Cloudflare Dashboard → Workers & Pages
  2. Select the worker
  3. Settings → Domains & Routes → Add Custom Domain

View Worker Logs

npx wrangler tail kwilo-api-proxy

Update DNS Records

  1. Cloudflare Dashboard → DNS → Records
  2. 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.

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.