Context
The two Cloud Run services must be reachable at their public subdomains with valid TLS:
api.prophy.net.br → prophy-backend
portal.prophy.net.br → prophy-frontend
Cloud Run's native domain mapping cannot be used. The feature is only available in ten regions, none of which is southamerica-east1 where both services run, and Google's own documentation marks it as preview and "not recommended for production services". Relocating the services was rejected: the database lives in the same region, and moving the application without it would add roughly 120 ms to every database query.
The domain sits on a Cloudflare Free plan. A plain proxied CNAME to the *.run.app hostname does not work: Cloudflare forwards the browser's original Host header, Cloud Run has no service registered under that name, and Google's edge returns a generic 404 before the request ever reaches the container. The fix is to rewrite the hostname on the request sent to the origin, and on a Free plan a Worker is the only mechanism that can do that — Cloudflare's Origin Rules "Host header override" action is Enterprise-only, Snippets are unavailable below Pro, and Transform Rules cannot touch Host at all.
Cost: US$0 on the Workers free tier (100,000 requests/day, account-wide). If that is ever exceeded, the paid tier is a flat US$5/month with no bandwidth charges.
Blocked on
The Cloudflare account belongs to a third party. A scoped API token and the Account ID are required before any of this can be executed.
Acceptance criteria
Access
Worker
Application configuration
Infrastructure
Verification
Documentation
Implementation guide
1. Worker
One Worker serves both subdomains, selecting the origin by incoming hostname. The line url.hostname = origin is what sets the outbound Host: on fetch(), Host follows the URL and cannot be set as a header.
const ORIGINS = {
"api.prophy.net.br": "prophy-backend-341810477176.southamerica-east1.run.app",
"portal.prophy.net.br": "prophy-frontend-341810477176.southamerica-east1.run.app",
};
export default {
async fetch(request) {
const url = new URL(request.url);
const origin = ORIGINS[url.hostname];
if (origin === undefined) {
return new Response("Unknown host", { status: 404 });
}
url.hostname = origin;
const proxied = new Request(url.toString(), request);
proxied.headers.set("X-Forwarded-Host", request.headers.get("Host"));
return fetch(proxied, { cache: "no-store" });
},
};
cache: "no-store" is deliberate: caching behaviour for an origin outside the Cloudflare zone is not clearly documented, and a cached authenticated response is the worst available failure mode. It can be relaxed for static frontend assets once the basic path is proven.
2. Order of operations
- Delete the existing
api and portal CNAME records.
- Deploy the Worker.
- Attach both hostnames as Custom Domains; Cloudflare creates the DNS records and certificates automatically.
- Update application configuration and redeploy (frontend requires a rebuild).
- Run the verification checklist above.
3. Security note
USE_X_FORWARDED_HOST means Django trusts a header the client can forge, because the *.run.app origin stays publicly reachable. ALLOWED_HOSTS is what bounds this — keep the allowlist exact and never use wildcards.
Rollback
Revert the GitHub Actions variables to the *.run.app hostnames, re-run both pipelines, and use gcloud run services update-traffic to return to the previous revisions. The Worker can be disabled independently by deleting its Custom Domains — note this does not remove the certificate Cloudflare provisioned, which must be deleted manually for a clean slate.
Known risks
| Risk |
Mitigation |
Confidence |
Multiple Set-Cookie headers folded into one |
Never rebuild response headers |
Low — source unavailable; mitigation is free, applied pre-emptively |
| Compression buffers streamed server-rendered responses |
Verify in a browser; disable compression per-route |
Medium — one production account |
| Authenticated response cached |
Explicit cache: "no-store" |
Low-medium — behaviour undocumented for out-of-zone origins |
| 100,000 requests/day is account-wide, shared by both subdomains |
Monitor; paid tier is US$5/month |
High |
Re-check later
If southamerica-east1 ever gains native Cloud Run domain mapping, or Cloudflare moves the Origin Rules Host-header override below Enterprise, this Worker becomes deletable. Worth re-checking around 2026-11 and 2026-12 respectively.
Context
The two Cloud Run services must be reachable at their public subdomains with valid TLS:
api.prophy.net.br→prophy-backendportal.prophy.net.br→prophy-frontendCloud Run's native domain mapping cannot be used. The feature is only available in ten regions, none of which is
southamerica-east1where both services run, and Google's own documentation marks it as preview and "not recommended for production services". Relocating the services was rejected: the database lives in the same region, and moving the application without it would add roughly 120 ms to every database query.The domain sits on a Cloudflare Free plan. A plain proxied CNAME to the
*.run.apphostname does not work: Cloudflare forwards the browser's originalHostheader, Cloud Run has no service registered under that name, and Google's edge returns a generic 404 before the request ever reaches the container. The fix is to rewrite the hostname on the request sent to the origin, and on a Free plan a Worker is the only mechanism that can do that — Cloudflare's Origin Rules "Host header override" action is Enterprise-only, Snippets are unavailable below Pro, and Transform Rules cannot touchHostat all.Cost: US$0 on the Workers free tier (100,000 requests/day, account-wide). If that is ever exceeded, the paid tier is a flat US$5/month with no bandwidth charges.
Blocked on
The Cloudflare account belongs to a third party. A scoped API token and the Account ID are required before any of this can be executed.
Acceptance criteria
Access
Edit Cloudflare Workerstemplate, zone-scoped toprophy.net.br)apiandportalCNAME records deleted — a Workers Custom Domain cannot be created on a hostname that already has a CNAMEWorker
X-Forwarded-Hostexplicitly (Cloudflare does not add it)Responseuntouched — no header rebuilding, which risks folding multipleSet-Cookieheaders into oneapi.prophy.net.brandportal.prophy.net.brbound as Workers Custom Domains (not Routes), with TLS activeApplication configuration
DJANGO_ALLOWED_HOSTScontains bothapi.prophy.net.brand the backend*.run.apphostname — requests arriving directly (Cloud Run startup probes, debugging) carry the latterUSE_X_FORWARDED_HOSTenabled so redirects and absolute URLs use the public hostnameCORS_ALLOWED_ORIGINS=https://portal.prophy.net.brCSRF_TRUSTED_ORIGINS=https://api.prophy.net.brNEXT_PUBLIC_HOST=https://api.prophy.net.br, baked via build arg — requires an image rebuild, not just a redeployDJANGO_ALLOWED_HOSTScan carry both namesInfrastructure
backend_run_host/frontend_run_host→ public domains, so uptime checks watch the path users actually takebackend_run_urlstays on*.run.app— Cloud Scheduler calls the origin directly, bypassing the proxy and not consuming the Worker request quota.OIDC_AUDIENCEmust match it exactly.Verification
https://api.prophy.net.br/api/docs/andhttps://portal.prophy.net.br/both return 200*.run.appURLs remain directly reachable for debuggingSet-Cookieheaders, not one comma-joined header (access + refresh must both survive the proxy)Documentation
docs/deployment.mdcustom-domain section updated to describe the Worker approachImplementation guide
1. Worker
One Worker serves both subdomains, selecting the origin by incoming hostname. The line
url.hostname = originis what sets the outboundHost: onfetch(),Hostfollows the URL and cannot be set as a header.cache: "no-store"is deliberate: caching behaviour for an origin outside the Cloudflare zone is not clearly documented, and a cached authenticated response is the worst available failure mode. It can be relaxed for static frontend assets once the basic path is proven.2. Order of operations
apiandportalCNAME records.3. Security note
USE_X_FORWARDED_HOSTmeans Django trusts a header the client can forge, because the*.run.apporigin stays publicly reachable.ALLOWED_HOSTSis what bounds this — keep the allowlist exact and never use wildcards.Rollback
Revert the GitHub Actions variables to the
*.run.apphostnames, re-run both pipelines, and usegcloud run services update-trafficto return to the previous revisions. The Worker can be disabled independently by deleting its Custom Domains — note this does not remove the certificate Cloudflare provisioned, which must be deleted manually for a clean slate.Known risks
Set-Cookieheaders folded into onecache: "no-store"Re-check later
If
southamerica-east1ever gains native Cloud Run domain mapping, or Cloudflare moves the Origin Rules Host-header override below Enterprise, this Worker becomes deletable. Worth re-checking around 2026-11 and 2026-12 respectively.