Skip to content

[INFRA] Serve custom domains via Cloudflare Worker reverse proxy #211

Description

@ledsouza

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

  • Scoped Cloudflare API token obtained (Edit Cloudflare Workers template, zone-scoped to prophy.net.br)
  • Cloudflare Account ID obtained
  • Existing api and portal CNAME records deleted — a Workers Custom Domain cannot be created on a hostname that already has a CNAME

Worker

  • Worker deployed, mapping each incoming hostname to its Cloud Run origin
  • Worker sets X-Forwarded-Host explicitly (Cloudflare does not add it)
  • Worker returns the origin Response untouched — no header rebuilding, which risks folding multiple Set-Cookie headers into one
  • api.prophy.net.br and portal.prophy.net.br bound as Workers Custom Domains (not Routes), with TLS active

Application configuration

  • DJANGO_ALLOWED_HOSTS contains both api.prophy.net.br and the backend *.run.app hostname — requests arriving directly (Cloud Run startup probes, debugging) carry the latter
  • USE_X_FORWARDED_HOST enabled so redirects and absolute URLs use the public hostname
  • CORS_ALLOWED_ORIGINS = https://portal.prophy.net.br
  • CSRF_TRUSTED_ORIGINS = https://api.prophy.net.br
  • NEXT_PUBLIC_HOST = https://api.prophy.net.br, baked via build arg — requires an image rebuild, not just a redeploy
  • New GitHub Actions variable for the origin hostname so DJANGO_ALLOWED_HOSTS can carry both names

Infrastructure

  • Terraform backend_run_host / frontend_run_host → public domains, so uptime checks watch the path users actually take
  • Terraform backend_run_url stays on *.run.app — Cloud Scheduler calls the origin directly, bypassing the proxy and not consuming the Worker request quota. OIDC_AUDIENCE must match it exactly.

Verification

  • https://api.prophy.net.br/api/docs/ and https://portal.prophy.net.br/ both return 200
  • The *.run.app URLs remain directly reachable for debugging
  • Login returns two separate Set-Cookie headers, not one comma-joined header (access + refresh must both survive the proxy)
  • Session persists across a page reload in a browser
  • Multi-megabyte file upload succeeds
  • Server-rendered pages stream correctly in a browser — "fast in curl, slow in browser" indicates Cloudflare's automatic compression buffering the response, which is disabled per-route

Documentation

  • docs/deployment.md custom-domain section updated to describe the Worker approach

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

  1. Delete the existing api and portal CNAME records.
  2. Deploy the Worker.
  3. Attach both hostnames as Custom Domains; Cloudflare creates the DNS records and certificates automatically.
  4. Update application configuration and redeploy (frontend requires a rebuild).
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions