This is live in production. globalopportunities.app is a Cloudflare
Worker (worker/index.js + wrangler.jsonc) that serves the static frontend
(frontend/) from the edge and proxies API calls to the Render backend
server-side. This doc is kept as the reference for how that's set up and for
reproducing it (e.g. a fresh Cloudflare account, or a new environment).
Before this move, globalopportunities.app was a Cloudflare-proxied DNS
record pointing directly at the Render service, which served both the API
and the static frontend from one Docker container — every visitor's
HTML/CSS/JS made a round trip to Render's origin server, with a possible
cold start on Render's free tier if it had been idle. Moving the static
frontend onto Cloudflare's edge network instead is faster worldwide, and
immune to Render cold-starts for anything that isn't an API call — without
touching the backend or admin auth at all.
Why "Workers" and not "Pages"? Cloudflare's own current guidance: "If you are starting a new project, use Workers instead of Pages. Pages continues to work, but new features and optimizations are focused on Workers." Since nothing has been deployed yet, this repo targets Workers with static assets directly rather than building on the product Cloudflare is de-emphasizing.
The naive version of this move — point the frontend at a different domain
than the API — breaks admin login. frontend/js/admin.js sends every
request with credentials: 'same-origin', and the session cookie
(app/routes/admin_auth.py) is SameSite=Strict. Both exist specifically
to reject cross-site requests; splitting the domains would require loosening
the cookie to SameSite=None and adding CSRF protection to compensate —
real security surface for what should be a pure infra change.
Instead, worker/index.js + wrangler.jsonc's assets binding make
Cloudflare serve every static file (index.html, admin.html, css/,
js/, robots.txt, ...) directly from the edge, and only fall through to
worker/index.js for paths with no matching file — which, by construction,
is exactly /api/*, /health, /docs, /openapi.json, and /redoc. The
worker proxies those server-side to the Render backend. From the browser's
point of view it's still one origin — zero changes to cookies,
admin.js, or CORS_ORIGINS.
npx wrangler login(or set aCLOUDFLARE_API_TOKENenv var scoped to Account → Workers Scripts → Edit and, for the custom-domain step below, Zone → DNS → Edit on theglobalopportunities.appzone).- From the repo root:
npx wrangler deploy --dry-runto validatewrangler.jsoncwithout publishing anything, thennpx wrangler deployto actually publish. This creates the Worker (global-opportunities) and uploadsfrontend/as its static assets in one operation. - Wrangler prints a
*.workers.devURL — verify there before touching DNS (see Verify below). - Custom domain:
npx wrangler deployagain after adding aroutesentry towrangler.jsonc, e.g.:Cloudflare handles the DNS change itself since the zone is already on Cloudflare.
- Workers & Pages (may appear simply as Workers, or under a
Compute section — it's an account-level item, not inside the
domain/zone view) → Create application → Workers → connect the
adab-tech/globalopportunitiesrepo, branchmain. - Build settings: none needed —
wrangler.jsoncat the repo root already declares everything (entry point, assets directory, theAPI_ORIGINdefault). - Settings → Variables: optionally override
API_ORIGINper environment (defaults to the Render.onrender.comURL already baked intowrangler.jsonc). - Deploy, then verify on the
*.workers.devURL Cloudflare gives you. - Settings → Domains & Routes → Add →
globalopportunities.app(andwww) once verified — same one-click DNS handoff as above, and just as reversible (remove the route, the old record comes back).
https://<worker>.workers.dev/→ the site loads normallyhttps://<worker>.workers.dev/health→ proxies through to Render, returns the same JSON the direct Render URL returnshttps://<worker>.workers.dev/admin.html→ log in as admin and confirm the moderation queue loads (this is the real test of the cookie/CORS behavior above)
A 2026-08 audit found no security headers (CSP, X-Frame-Options,
X-Content-Type-Options, etc.) being sent anywhere. Two separate places
needed them, since the two halves of this deployment never share a runtime:
- The API/docs, proxied to Render —
backend/app/main.pyhas an ASGI middleware (add_security_headers) that sets them on every response, with a path-scopedContent-Security-Policy: strict for JSON endpoints, a looser CDN/inline-allowing one for/docsand/redoc(FastAPI's stock Swagger UI / ReDoc pages), and one matching the frontend's below for the local-dev fallback where this same app servesfrontend/directly. - The static site, edge-served —
frontend/_headers(Cloudflare's native mechanism for this — see Headers — confirmed via the Cloudflare docs MCP tool rather than assumed, since it supersedes the old Pages-only convention; norun_worker_firstneeded since nothing here has to run per-request). Verified against a realwrangler devlocally, including a gotcha the docs don't call out: hitting/admin.html307-redirects to the extensionless/admin, so the CSP rule has to target/admintoo or the page that actually gets served carries no policy at all.
Strict-Transport-Security is unconditional in _headers (Cloudflare only
serves this site over HTTPS) but conditional in the backend middleware on
settings.SESSION_COOKIE_SECURE — the existing flag for "this is plain-http
local dev" — so local development never gets an HSTS header a plain-http
server can't honour.
- The Render service keeps running exactly as-is — same Docker image, same
render.yaml, same env vars. It's now reached only via the worker's server-to-serverfetch()rather than directly by browsers, but nothing about its own code or config needs to change. CORS_ORIGINScan stay*— CORS only governs browser-initiated cross-origin requests, and after this move browsers never talk to Render directly.- Admin auth, session cookies, and every
admin.jsfetch call are untouched.