Cloudflare
Cloudflare does everything domain-related and hosts every static app. It was chosen for the free tier, the global edge, R2's free terabyte with no egress fees, and Access for gating internal sites.
What runs where
| Product | Used for | Names |
|---|---|---|
| DNS | Zones everymanawarrior.cc (apps) and emaw-app.com (backend). everymanawarrior.com is the legacy marketing domain. | |
| Pages | Static React apps deployed with wrangler pages deploy from GitHub Actions | emaw-website, emaw-react-web-app, emaw-documentation |
| Workers (static assets + a small proxy) | The Admin CRM, because it needs a same-origin /rest/* proxy | emaw-admin-dev, emaw-admin |
| R2 | Website resource media (audio, video, documents): no egress fees, 1-year edge cache | bucket emaw-public, served from r2.everymanawarrior.cc |
Tunnel (cloudflared) | Exposes the backend without inbound ports; one tunnel per environment | hostnames dev.emaw-app.com, app.emaw-app.com |
| Access (Zero Trust) | Google Workspace login in front of internal sites | dev website, admin CRM (dev and prod), docs |
| Cache Rules | 1-year TTL on r2.everymanawarrior.cc |
Pages projects
| Project | Repo | develop branch | main branch |
|---|---|---|---|
emaw-website | emaw-website | develop.emaw-website.pages.dev → develop-website.everymanawarrior.cc | manual deploy; production custom domain to be everymanawarrior.com at launch |
emaw-react-web-app | emaw-web-app | develop-web-app.everymanawarrior.cc | app.everymanawarrior.cc |
emaw-documentation | emaw-documentation | docs-dev.everymanawarrior.cc | docs.everymanawarrior.cc |
All three are Direct Upload projects: the GitHub workflow builds and runs npx wrangler@4 pages deploy <dir> --project-name <project> --branch <develop|main>. The Pages Git integration is not used. Secrets per repo: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
Build-time variables for the website come from GitHub repository variables (VITE_SANITY_*, VITE_EMAW_API_BASE_URL, VITE_STRIPE_PUBLISHABLE_KEY, VITE_GOOGLE_CLIENT_ID, VITE_EMAW_APP_URL); the web app and CRM commit their .env.development / .env.production instead.
Workers: the Admin CRM
emaw-crm-app/wrangler.jsonc deploys dist/ as a Worker with static assets, single-page-app fallback, and run_worker_first: ["/rest/*"]. The Worker (worker/index.ts) forwards /rest/* to API_ORIGIN (injected at deploy from VITE_API_BASE_URL) and copies the Cf-Access-Jwt-Assertion header into X-Cf-Access-Jwt. Everything else is a 404 from the Worker and a static asset otherwise.
| Environment | Worker | Domain | Deploy |
|---|---|---|---|
| development | emaw-admin-dev | admin-dev.everymanawarrior.cc | GitHub Action on push to develop |
| production | emaw-admin | admin.everymanawarrior.cc | npm run deploy:production by hand |
Custom domains on Workers create their own DNS records. public/_headers sets no-framing, nosniff, HSTS and referrer policy.
R2: emaw-public
- Public bucket behind the custom domain
r2.everymanawarrior.cc, with a zone-level Cache Rule for a 1-year TTL and a CORS rule that allows the admin origins toPUT. - The backend presigns direct browser uploads (
resources/r2.py); the Admin CRMPUTs the file straight to R2; the public API returns the fullmedia.url, so the website never constructs R2 URLs. - Keys are immutable. Replacing a file uploads a new key. Never overwrite a published key; the edge cache would keep serving the old bytes for up to a year.
- Backend env vars:
R2_ACCOUNT_ID,R2_BUCKET,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY(API token with Object Read & Write on the bucket),R2_PUBLIC_BASE_URL. Uploads return 503 until all four credentials are set. - Setup and troubleshooting:
docs/r2-setup.mdandresources/README.md.
Tunnels
Each backend environment has a remotely managed tunnel (configured in the Zero Trust dashboard, not in a config file). The compose tunnel service runs cloudflared tunnel run --token ${TUNNEL_TOKEN}; the token comes from .env and is not passed into the Django containers.
Consequences:
- gunicorn's port is never published; only the compose network reaches it (
--forwarded-allow-ips '*',TRUST_CF_CONNECTING_IPfor the real client IP). - Running two connectors with the same token (the dev VM and your laptop) makes Cloudflare split traffic between them. Stop the server's stack before you take the dev tunnel; see Backend local development.
- Stripe webhooks reach a developer's local stack through the tunnel too, which is why the Stripe CLI bridge in
payments/README.mdtargets the tunnel hostname.
Access
Access applications (Zero Trust → Access → Applications) use the Google Workspace identity provider with the policy emails ending in @everymanawarrior.com:
| Application | Why |
|---|---|
develop-website.everymanawarrior.cc | Staff-only testing of the new site before launch |
admin-dev.everymanawarrior.cc, admin.everymanawarrior.cc | Protects the CRM database from anyone outside the workspace |
docs-dev.everymanawarrior.cc, docs.everymanawarrior.cc | Internal documentation |
For the admin app the backend additionally verifies the Access JWT (CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD) on every staff endpoint, so an app password alone is never enough. Locally both variables are unset and the check is skipped.
Account access
Changes in the Cloudflare dashboard (new Pages project, Access policy, DNS record, R2 token) need account access; ask Matt. Day-to-day deploys only need the API token already stored in each repo's GitHub environments.