Three Layers That Keep Cloud Run Traffic Behind Cloudflare
A public run.app URL lets anyone bypass your CDN and hit Cloud Run directly. Three layers close that gap: ingress limits, load balancer, secret header.
Put Cloudflare in front of a Cloud Run service and you get caching, a WAF, and DDoS absorption. None of that helps if the origin is still reachable at its default run.app URL, because an attacker who finds that URL walks straight past every protection you set up. The fix is not one setting. It is three independent layers, each of which fails closed on its own: locked ingress at the network edge, a load balancer that becomes the only public door, and a secret header that proves a request actually came through Cloudflare. This post walks through all three, the config for each, and when the whole stack is more than you need.
The gap: run.app is public by default
When you deploy a Cloud Run service, Google hands you a URL like https://my-service-abc123-an.a.run.app. By default that URL serves the internet directly. You then point a domain at Cloudflare, Cloudflare proxies to your service, and traffic flows through the CDN. This looks secure. It is not.
The problem is that the run.app URL keeps working. Anyone who learns it, and these URLs are guessable, logged, and leaked in referrer headers and error pages, can send requests straight to the origin. That direct path skips your cache, so every request is a cold hit that costs you compute. It skips your WAF, so injection and bot filtering never run. And it skips Cloudflare's rate limiting, so a single script can hammer the service until your bill or your latency falls over.
Hiding the URL is not a defense. Security by obscurity fails the moment the string appears in one log aggregator. The origin needs to actively refuse anything that did not come through the front door, and the front door needs to be the only way in.
Layer one: lock Cloud Run ingress to the load balancer
Cloud Run has an ingress setting that controls which networks can reach the service. The default is all, which is the public run.app behavior. The value you want is internal-and-cloud-load-balancing, which drops all direct traffic and only accepts requests that arrive through a Google Cloud load balancer or from inside your VPC.
gcloud run services update my-service \
--region=asia-northeast3 \
--ingress=internal-and-cloud-load-balancing
After this change, a request to the raw run.app URL returns a 404 from Google's edge before it ever reaches your container. The service is no longer on the public internet. It is only reachable through the load balancer you are about to build, which is exactly the choke point you want.
This one setting does most of the work, and it is worth understanding why it is not enough by itself. Ingress control is a network fact: it filters by where the request entered, not by whether the request is legitimate. Once you put a load balancer in front and expose that, the load balancer itself is public. Anything that reaches the load balancer reaches your service. So layer one narrows the entry to a single balancer, and layers two and three decide what that balancer is allowed to forward.
Layer two: put a load balancer behind Cloudflare
With ingress locked, the only thing that can reach Cloud Run is a Google Cloud load balancer. You build an external HTTPS load balancer whose backend is a Serverless Network Endpoint Group (NEG) pointing at the service. The NEG is the adapter that lets a load balancer target a serverless product that has no fixed IP or instance group.
# 1. A serverless NEG that points at the Cloud Run service
gcloud compute network-endpoint-groups create my-service-neg \
--region=asia-northeast3 \
--network-endpoint-type=serverless \
--cloud-run-service=my-service
# 2. A backend service that uses the NEG
gcloud compute backend-services create my-service-backend \
--global \
--load-balancing-scheme=EXTERNAL_MANAGED
gcloud compute backend-services add-backend my-service-backend \
--global \
--network-endpoint-group=my-service-neg \
--network-endpoint-group-region=asia-northeast3
From there you attach a URL map, an HTTPS proxy with a certificate, and a global forwarding rule with a static IP. That IP is what Cloudflare proxies to. In the Cloudflare dashboard you set the DNS record for your domain to that IP with the proxy status on, the orange cloud. Now the public path is user, then Cloudflare, then the load balancer IP, then the NEG, then Cloud Run.
At this point you have two doors, and only one of them should be open. Cloudflare is the intended door. The load balancer IP is a second door that is still public, because a global forwarding rule answers the whole internet. Someone who resolves your domain's real origin IP, or scans the address range, can talk to the load balancer directly and bypass Cloudflare again. Layer two moved the exposed surface from run.app to a raw IP, which is harder to find but not closed. That is what layer three closes.
Layer three: a secret header only Cloudflare can add
The final layer proves that a request passed through Cloudflare, and it does this at the application level rather than the network level. Cloudflare injects a secret header into every request it proxies, and the origin refuses any request that does not carry the correct value. An attacker hitting the load balancer IP directly cannot forge this header, because they do not know the secret.
You add the header with a Cloudflare Transform Rule (Rules, then Transform Rules, then Modify Request Header). The rule sets a custom header on all incoming requests before they leave for the origin:
Rule: Add origin secret
When: (all incoming requests)
Then: Set static header
Header name: X-Origin-Secret
Value: <a long random string from Secret Manager>
The origin then checks two things on every request: that the header is present and matches, and that the Host is the domain you expect. The Host check catches requests that carry a stale or copied header but target the wrong virtual host. In Go the middleware is small:
func requireCloudflare(secret, wantHost string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Origin-Secret")), []byte(secret)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Host != wantHost {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Use a constant-time compare so the check does not leak the secret through timing. Load the secret from Secret Manager rather than baking it into the image, which keeps it out of your source and your container layers, and lets you rotate it. Rotation is a two-step dance: add the new value as a second accepted secret on the origin, switch the Transform Rule to the new value, then drop the old one once traffic has drained. No request is rejected during the swap.
The full request path
Here is the whole flow once all three layers are in place. Each hop has a job, and each rejection point is a layer failing closed.
| Hop | Component | Job | Rejects when |
|---|---|---|---|
| 1 | Cloudflare | Cache, WAF, rate limit, add X-Origin-Secret |
Bot or attack signature matches WAF |
| 2 | GCP load balancer | Terminate TLS, route via URL map | (forwards; not a security gate on its own) |
| 3 | Serverless NEG | Adapt to Cloud Run | n/a |
| 4 | Cloud Run ingress | Drop non load balancer traffic | Request did not arrive via the balancer or VPC |
| 5 | Origin middleware | Verify header and Host | Header missing, wrong, or Host mismatched |
A legitimate visitor passes all five. A request to the raw run.app URL dies at hop 4. A request to the load balancer IP that skips Cloudflare passes hop 4 but dies at hop 5, because it has no secret header. There is no single path that reaches your handler without going through Cloudflare.
Why three layers instead of one
The obvious question is why bother with all three when the secret header alone seems to close the bypass. The answer is that each layer covers a different failure of the others, and real incidents come from the failures, not the happy path.
Ingress control is a network guarantee that does not depend on your code being correct. If you ship a bug that skips the header middleware on some route, ingress still keeps the raw run.app URL dark. The secret header is an application guarantee that does not depend on Google's network config being correct. If someone fat-fingers the ingress setting back to all during an unrelated change, the header check still rejects direct hits. The Host verification catches a narrower case: a valid header replayed against the wrong service or a misrouted request.
These are independent because they fail independently. A single control means a single mistake, one wrong click or one missed code path, drops your guard to zero. Three controls mean a single mistake drops you from three layers to two, and you are still protected while you notice and fix it. The point of defense in depth is not that any one layer is weak. It is that all of them being wrong at the same time is far less likely than one of them being wrong.
The costs, and when this is overkill
None of this is free. The external HTTPS load balancer has a fixed hourly charge that runs on the order of eighteen dollars a month before you serve a single request, plus per-rule and data costs. For a hobby service that bill can dwarf the compute it protects. The setup is also more moving parts: a NEG, a backend service, a URL map, a proxy, a certificate, a forwarding rule, a Transform Rule, and secret rotation to keep working. That is more surface to misconfigure and more to reason about when something breaks.
There are lighter options, and they are the right call for many services. Cloudflare's own Origin Rules plus authenticated origin pulls (mutual TLS between Cloudflare and your origin) can prove the connection came from Cloudflare without a GCP load balancer at all, though on Cloud Run you still need something to keep the run.app URL from answering. The secret header on its own, without ingress locking, stops the casual bypass but leaves the origin publicly reachable and relying entirely on your code being right. Each of these trades a layer of depth for lower cost and less setup.
The full stack is overkill when the threat is low and the blast radius is small. An internal tool behind an identity proxy, a low-traffic side project, or a service where a direct hit costs cents rather than dollars does not need three layers. Reach for the full triple protection when direct origin access would actually hurt: when cache bypass means real money, when the WAF is doing meaningful work, or when a determined attacker finding your origin IP is a plausible event rather than a theoretical one. Match the number of layers to what a breach of the front door would cost you, and stop there.
Related posts
Keyless GitHub Actions Deploys to GCP with Workload Identity
Stop pasting service account JSON keys into GitHub Secrets. Workload Identity Federation lets Actions authenticate to GCP with short-lived OIDC tokens.
Cloud Run Service vs Job: One Go Binary, Two Entry Points
A Cloud Run Service and Job can share one Go image and one build. Here is how a single binary splits into a serve mode and batch jobs, and when not to.
How Cloud Run CPU Throttling Breaks Background Goroutines
Cloud Run gives your container CPU only during requests, so tickers, heartbeats, and cache refreshes quietly stall. Here is why, and four ways to fix it.
How to Rotate JWT Signing Keys Without Logging Everyone Out
Rotating a JWT signing key naively invalidates every live token and logs out all users. Here is the overlap window and kid design that avoids it.