I moved my domain from the registrar's nameservers to Cloudflare Pro in a single afternoon. Multiple Cloud Run services across two GCP projects, Google Workspace email, and a domain that had never been behind a CDN or WAF. The Cloudflare documentation covers the happy path well. This piece covers everything else.
The migration ran twelve tasks across three phases: DNS setup, proxy activation, and hardening. Most of the difficulty lived between the tasks, in sequencing decisions and UI gotchas the documentation does not mention. Four stood out: gray-cloud sequencing for certificate provisioning, email as the highest-risk service, HSTS preload as a long-term commitment, and a double-authentication pattern I ended up keeping.
Gray cloud first, orange cloud later
Cloud Run domain mappings use Google-managed certificates. Google does not document the validation mechanism - the docs commit only to certificates being "automatically issued and renewed" - but they are explicit about the failure mode: a third-party CDN can inadvertently intercept validation requests, causing the domain mapping to fail or its certificate to fail to renew. The docs call out Cloudflare by name, with turning off the Always Use HTTPS edge setting as the suggested fix. The mechanism is consistent with ACME HTTP-01 (both CAs Google issues from, Google Trust Services and Let's Encrypt, are ACME CAs), but treat that as inference rather than documentation.
The fix is ordering: every Cloud Run subdomain starts with the proxy off (gray cloud in Cloudflare's UI), stays gray until Google provisions the cert, then flips to proxied (orange cloud) one subdomain at a time. If a flip breaks something, you know exactly which record caused it and can revert it without touching the others.
The harder problem is what happens after the flip. Google-managed certificates in the sibling products (load balancer certs, Certificate Manager) are valid for 90 days and renew automatically before expiry; Cloud Run's docs give no cadence, but the renewal-through-a-proxy risk they describe applies on whatever the real timer is. Google's documented fix is blunt: turn off the zone-wide Always Use HTTPS setting, because the validation request arrives over plain HTTP and an edge redirect kills it before it reaches Google. I added a WAF skip rule on /.well-known/acme-challenge/ so challenge requests clear WAF processing, but be clear about what that rule does not do: a WAF skip does not bypass the Always Use HTTPS redirect, so on its own it is a partial mitigation. I have not yet watched a renewal succeed through the proxy, so the fallback is scheduled rather than assumed: a 75-day calendar reminder per subdomain, and if the first proxied renewal fails, that record goes back to gray cloud (or Always Use HTTPS goes off) until the new cert lands.
Email is the highest-risk service in a DNS migration
Changing nameservers is a DNS migration, and the highest-impact service to break is email. The failure mode is quieter than people expect: if MX records are wrong or missing after cutover, sending servers queue and retry for a few days, then bounce the message back to the sender - RFC 5321 requires it. The domain owner never sees those bounces. Your inbox just goes quiet, and by the time someone tells you out-of-band, days of mail have already been returned to senders. The one genuinely silent case is an MX pointing at a host that accepts and discards - then nobody gets a signal.
The migration also tightened mail authentication: SPF with -all hard fail, DKIM with 2048-bit keys, DMARC with strict alignment (adkim=s; aspf=s) starting at p=quarantine and tightening to p=reject after clean aggregate reports. Two of those choices are stricter than Google's own guidance - Google recommends ~all and relaxed alignment - and they are viable here only because this domain has exactly one sending service and no subdomain senders. If your domain has forwarding, mailing lists, or third-party senders in play, strict alignment and -all will cost you legitimate mail: start at p=none and read the aggregate reports first.
The pre-flight check is critical: query Cloudflare's assigned nameservers directly before changing anything at the registrar. If the MX records resolve correctly against Cloudflare's NS, they will resolve correctly after cutover. Verify email immediately after propagation - do not wait until someone reports missing mail.
HSTS with preload is a one-way door
Cloudflare makes it easy to enable HSTS with subdomain inclusion and preload. Once a browser has seen the header, it upgrades every HTTP request for the domain and all subdomains to HTTPS before anything leaves the machine, for the max-age duration (12 months in my case - exactly the preload list's minimum). The preload flag is consent, not submission: getting into browser preload lists requires submitting the domain at hstspreload.org, and because the flag signals consent, anyone can submit it for you. Checking while writing this: rehagen.net is still not on the list, because I never submitted it. Once a domain is in, the policy outlives the header - removal is a separate request that takes six to twelve weeks to reach most Chrome users, longer for other browsers.
For a new Cloudflare zone where everything speaks HTTPS from day one, that commitment costs nothing. The constraint bites later: any future subdomain that needs plain HTTP - a legacy device, a delegated zone, an internal tool - will be blocked by a policy you set months earlier and forgot about. That is the profile of a hard-to-diagnose outage, and it is why preload deserves a deliberate decision rather than a checkbox.
Double authentication was not in the plan
The private app runs behind Google's Identity-Aware Proxy. Adding Cloudflare Access on top was originally a convenience: edge-level identity enforcement so unauthenticated requests never reach Cloud Run. What I did not plan for was keeping both.
With Cloudflare Access and IAP both active, the request passes through two identity checks. Cloudflare Access verifies at the edge via Google OAuth. IAP verifies again at the Cloud Run service.
In practice, when accessed from Chrome with a Google profile already signed in, the experience is one login: both checks reuse the active Google session and complete without a second prompt. That is observed behavior, not a guarantee - Access and IAP are separate OAuth clients with separate cookies, and account-selection or reauthentication prompts can resurface either one. The two-prompt experience shows up on cold sessions: a fresh browser, an incognito window, or a non-Chrome browser without a cached Google session.
The security argument for keeping both layers: if someone finds the .run.app URL - stable, guessable, and enumerated by scanners - the request bypasses Cloudflare entirely, and IAP is what blocks it (at Google's front end, before the request reaches the container). Removing IAP leaves protection resting on that URL staying unknown, which is obscurity standing in for access control. Removing Cloudflare Access pushes every probe through to Google's infrastructure before IAP turns it away, and gives up the WAF filtering, bot scoring, and edge analytics that come with rejecting junk at Cloudflare.
For a daily-use personal app accessed through a logged-in Chrome profile, the friction is essentially zero. The cleaner long-term design is to validate the Cloudflare Access JWT (the Cf-Access-Jwt-Assertion header) in the application layer and retire IAP: one identity check at the edge, with direct .run.app requests rejected because they carry no valid JWT. That collapses cold sessions to one prompt. It is a code change I have not made yet.
The twelve-task migration
The full migration plan lives in the project's internal documentation. This section covers the tasks where the documentation was wrong, incomplete, or misleading.
Discard addresses for redirect-only records
The apex domain and www subdomain serve no content - they 301 redirect to the main site subdomain. Cloudflare needs a DNS record to proxy, even for redirect-only rules. Cloudflare's docs sanction two placeholders: 192.0.2.1 (a documentation IP per RFC 5737) for A records, or 100:: for AAAA records - a discard address per RFC 6666. Cloudflare never connects to the origin for redirect-only rules, so the value does not matter functionally. I use the discard address because it states the intent: this traffic goes nowhere by design.
Type Name Value Proxy
AAAA example.com 100:: Proxied (orange)
CNAME www example.com Proxied (orange)
CAA records: allow both Google CAs, and mind the UI
A CAA record restricts which CAs can issue certificates for your domain, and on this stack it has two traps. The first is the CA list. Google-managed certificates are issued by Google Trust Services (pki.goog) or Let's Encrypt (letsencrypt.org), and Google chooses automatically - its docs say to allow both, and explicitly call a single-CA record not recommended, because a renewal can move to the other CA.
The second trap is that CAA behaves differently on a Cloudflare zone. With Universal SSL active, Cloudflare auto-appends its own issuing CAs to your zone's CAA responses. My dashboard holds a single pki.goog record; dig rehagen.net CAA returns five issue entries. That auto-append is quietly load-bearing: Cloudflare's edge certificate for this zone is Let's Encrypt-issued, and my dashboard record alone would have forbidden it. Audit the effective record with dig, not the dashboard.
The UI has its own gotcha. The Cloudflare dashboard has separate fields for flag, tag, and CA domain name. Enter only pki.goog in the domain name field - not the full 0 issue "pki.goog" syntax. The UI assembles the record from the individual fields. Putting the full string in the domain name field creates a malformed record that silently fails.
The Access policy email commit bug
When creating a Cloudflare Access application and adding an email to an Include rule, typing the email and clicking Save does not work. You must press Enter after typing the email to register it as a tag. The UI shows the email in the field, but it has not been committed. Saving without pressing Enter produces: access.api.error.invalid_request: include field should not be empty. The email appears to be there but the backend never received it.
gcloud beta for domain mappings
gcloud run domain-mappings create targets the Knative surface and may fail or lack flags for fully managed Cloud Run - Google's own reference redirects you to gcloud beta run domain-mappings create. The beta prefix is not an accident: domain mappings have sat in preview for years, Google's docs still mark them not production-ready due to latency issues, and they only work in a short list of regions. Google points production traffic at a global external Application Load Balancer or Firebase Hosting instead. For a personal stack, preview is an acceptable trade - but make it a decision, not a surprise. Each service may run in a different GCP project and region - always specify --project and --region explicitly:
gcloud beta run domain-mappings create \
--service=my-service \
--domain=app.example.com \
--project=my-project \
--region=us-east1
cf-ray as the proxy verification signal
After flipping a subdomain to proxied, the confirmation is the cf-ray response header. The format is <hex>-<POP>, where the three-letter suffix identifies the Cloudflare edge location serving the request:
curl -sI https://app.example.com | grep cf-ray
# cf-ray: 938a1bc23def-BOS
A missing cf-ray header means traffic is not going through Cloudflare's proxy, regardless of what the dashboard shows.
Architecture summary
| Hostname | Purpose | Cloudflare Role | Origin Auth |
|---|---|---|---|
| example.com (apex) | 301 redirect to site subdomain | Edge redirect only, no origin | N/A |
| www.example.com | 301 redirect to site subdomain | Edge redirect only, no origin | N/A |
| site.example.com | Public site | Full proxy: WAF, CDN, analytics | Cloud Run (public) |
| app.example.com | Private app | Cloudflare Access + full proxy | Cloud Run + IAP |
Header enrichment through the proxy
Every proxied request carries Cloudflare headers that the application must read for accurate logging:
| Header | Value | Notes |
|---|---|---|
| CF-Connecting-IP | Real client IP | Only trust when the connection provably came through Cloudflare (see below) |
| CF-IPCountry | Two-letter country code (XX/T1 for unknown/Tor) |
Free geo enrichment; gated on the zone's IP Geolocation setting |
| CF-Ray | Request/trace ID (uniqueness not guaranteed) | Correlate Cloudflare analytics with application logs |
The trust validation matters: anyone hitting the .run.app URL directly can set CF-Connecting-IP to any value. The standard advice - trust the header only when the connecting IP is in Cloudflare's published ranges - needs adapting on Cloud Run, because the TCP peer your application sees is often Google's front end or Cloud Run's internal HTTP/2 proxy, not Cloudflare. My logger accepts CF-Connecting-IP in exactly two cases: the socket peer is in Cloudflare's ranges, or the peer is Cloud Run's internal proxy and a cf-ray header is present. It never falls back to X-Forwarded-For, which the client can seed; everything else logs the raw socket peer. Be honest about the second clause: it is a heuristic, not proof. A client that reaches the origin without going through Cloudflare traverses the same internal proxy and can forge both cf-ray and CF-Connecting-IP.
The tempting fix is to disable the default *.run.app URL (default_uri_disabled), and I do that - but it is not sufficient on its own. A Cloud Run domain mapping is served by Google's frontend at a stable, publicly resolvable IP; point a Host: your-domain request straight at that IP and you reach the origin, Cloudflare skipped entirely. I verified this against my own site. So the header heuristic is a log-quality measure, not a security boundary: treat those enriched IPs as best-effort, and anything that must be trustworthy (rate-limit keys, honeypot attribution) has to fail closed on the raw socket peer. Genuinely closing the bypass means restricting origin ingress to Cloudflare - internal-only ingress with the proxy reaching the origin over a private path, or authenticated origin pulls - not just hiding the run.app name.
SSL/TLS configuration
| Setting | Value | Rationale |
|---|---|---|
| Encryption mode | Full (Strict) | Cloud Run serves HTTPS with valid Google-managed certs. Flexible would send plain HTTP to an origin that redirects to HTTPS - an infinite loop. |
| Minimum TLS | 1.2 | TLS 1.0/1.1 deprecated |
| HSTS Max Age | 12 months | With preload and subdomain inclusion; 12 months is the preload minimum |
| Rocket Loader | OFF | Rewrites script loading and is known to break React-style apps; Cloudflare's remedy is disabling it or per-script data-cfasync="false" |
| HTTP/3 (QUIC) | ON | Faster connection establishment |
| 0-RTT | ON | Skips a round-trip on resumed connections. Replay risk is bounded: Cloudflare only allows early data on GET/HEAD/OPTIONS and marks it with Early-Data: 1 so origins can reject sensitive requests with 425 |
Part of Personal Infra & GCP - production discipline applied to one operator.
References
- Google Cloud: Domain mappings for Cloud Run
- Google Cloud: Google-managed SSL certificates (CAA guidance)
- Cloudflare Docs: Configure a custom rule with the Skip action
- Cloudflare Docs: Redirect one domain to another (placeholder records)
- Cloudflare Docs: 0-RTT connection resumption
- Cloudflare: Published IP Ranges
- RFC 6666: A Discard Prefix for IPv6
- RFC 5321: SMTP (queuing, retry, and non-delivery requirements)
- HSTS Preload List submission and removal