Chapter 10

Cloud Run and Serverless Containers

Scope. This chapter covers Cloud Run as a serverless container runtime: services and jobs, the execution model and container contract, scaling and concurrency, ingress and invocation authentication, networking to a VPC and to Cloud SQL, secrets, observability, and deployment strategy, closing with three applied patterns. It does not cover Kubernetes (Chapter 9), load balancer or Cloud Armor internals (Chapter 7), the Secret Manager resource model (Chapter 13), or the Cloud SQL Auth Proxy itself (§12.17) — this chapter shows only the Cloud Run-side binding to each. Prerequisites. §2.30§2.31 (organization policy mechanics), §3.9 and §3.4 (IAM roles and groups), §5.4 (subnets), §5.19 (Private Service Connect), §7.6 and §7.13 (backend services and Cloud Armor), §8.29 (service account attachment pattern). Verified against. Google Cloud console and API surface as of 2026-09, Cloud SDK 583.0.0; see sources at end.

Cloud Run runs a container in response to HTTP requests, Pub/Sub messages, or a scheduled invocation, and bills for what the container actually uses rather than for a reserved machine. That model changes where the security work goes. There is no host to patch and no node pool to harden, so the controls that matter shift to the container image, the identity the revision runs as, the network path it is allowed to take, and who is allowed to invoke it at all. A misconfigured Cloud Run service does not leak through an open port on a VM; it leaks through a *.run.app URL that answers requests from the entire internet by default, or through a default compute service account carrying permissions the code never needed.

The rc-saas estate in this book runs its public and internal HTTP surfaces on Cloud Run inside rc-saas-prod-app-01, pulling images from Artifact Registry in rc-saas-shared-art-01, reaching Cloud SQL in rc-saas-prod-data-01, and fronted — where the estate requires it — by a global external Application Load Balancer built in Chapter 7. Every example in this chapter uses that placement: us-central1 as the primary region, the 10.128.0.0/20 app subnet from the fixed CIDR plan, and a dedicated sa-run-<service> service account per Cloud Run service rather than the default compute service account, which is never attached to a Cloud Run resource in this book because it carries broad project-level permissions no single service needs.

This chapter treats Cloud Run as the default compute choice for stateless HTTP workloads and event-driven background work, and treats GKE (Chapter 9) as the choice only when a workload needs something Cloud Run structurally cannot give it — a persistent local filesystem, a non-HTTP listener protocol at the container ingress, or fine-grained node-level control.

10.1 Cloud Run Architecture §

Cloud Run is a fully managed platform that runs stateless, request-driven (or event-driven) containers without asking you to provision or manage a VM, cluster, or node pool. Google owns the underlying execution environment; you own the container image, its configuration, and the IAM policy that governs who can deploy and who can invoke it. Two resource types share this runtime: services (§10.2), which answer HTTP requests and scale on demand, including to zero, and jobs (§10.3), which run a container to completion for a fixed number of tasks and then stop. Both are regional resources — a service or job lives in one region, and the estate's us-central1 and europe-west1 regions each get independent deployments when a workload needs both.

A service is configured, not just deployed: each gcloud run deploy or Terraform apply that changes the image, environment variables, resource limits, or almost any other setting creates a new immutable revision (§10.4). The service is a stable name and URL; the revision is the versioned, immutable unit that actually runs. Traffic is assigned to one or more revisions by percentage, which is what makes canary and blue/green rollouts (§10.25) possible without any external traffic-splitting layer.

The request path for a fully managed Cloud Run service looks like this:

A request's path into a Cloud Run container, top to bottom, with what happens at each step. Client. Cloud Run front end, which terminates TLS and routes to the service and revision. IAM invoker check, unless the ingress setting bypasses it. Ingress setting check, one of all, internal, or internal-and-cloud-load-balancing. Container instance, which may be existing, warm, or newly started. Application code, listening on $PORT.

Every request to the *.run.app default URL passes through this front end regardless of whether a load balancer also sits in front of the service — the default URL always exists unless explicitly disabled, which is why ingress restriction (§10.11) and invoker IAM (§10.14) are the two controls that actually determine reachability, not the presence of a load balancer alone.

Google's execution-model documentation makes the CPU allocation model an explicit choice with security and cost consequences (§10.8): "CPU is only allocated during request processing" under the default request-based billing setting, versus allocated "for the entire container instance lifecycle" under instance-based billing. That distinction is why a background thread — a queue poller, a metrics flusher — silently stalls under the default setting but runs continuously under the other, and it is the first thing to check when a service behaves correctly under load but not at idle.

IAM permissions. Deploying and managing services requires roles/run.developer (deploy, update, delete) or roles/run.admin (all of that plus IAM policy management on the service itself); invoking a service requires roles/run.invoker, granted separately and covered in §10.14. roles/run.viewer is read-only. None of these roles are basic roles — no example in this book grants Owner or Editor to a Cloud Run principal.

Pitfall. Treating "deployed" as "reachable only as intended." A service deployed with no --ingress flag defaults to all and no --allow-unauthenticated flag defaults to requiring IAM — but a single later gcloud run services add-iam-policy-binding granting the invoker role to a public principal, run once during testing and never reverted, silently makes a supposedly private service public. Audit invoker bindings as part of routine review, not only at deploy time.

10.2 Services §

A Cloud Run service is the resource type for request-driven workloads: it has a stable name, a default *.run.app URL, an IAM policy controlling both administration and invocation, and one or more revisions splitting traffic. This book names services <app>-<env>checkout-prod, billing-api-prod — so the environment is visible in the name without a separate lookup.

IMAGE="us-central1-docker.pkg.dev/rc-saas-shared-art-01/checkout/checkout:1.4.0"
gcloud run deploy checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --image="${IMAGE}" \
  --service-account=sa-run-checkout-prod@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --no-allow-unauthenticated \
  --ingress=internal-and-cloud-load-balancing \
  --min-instances=1 \
  --max-instances=20

Console path: Console → Cloud Run → Create service, selecting an existing Artifact Registry image, then under Authentication choosing "Require authentication" and under Container(s), Volumes, Networking, Security → Networking restricting ingress. Every field the CLI flags above set has a corresponding console field; there is no service configuration that requires the CLI or API exclusively.

IaC. The Terraform v2 resource is google_cloud_run_v2_service:

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.0"
    }
  }
}

resource "google_cloud_run_v2_service" "checkout_prod" {
  name     = "checkout-prod"
  location = "us-central1"
  project  = "rc-saas-prod-app-01"
  ingress  = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  template {
    service_account = google_service_account.run_checkout_prod.email
    scaling {
      min_instance_count = 1
      max_instance_count = 20
    }
    containers {
      image = "us-central1-docker.pkg.dev/rc-saas-shared-art-01/checkout/checkout:1.4.0"
    }
  }
}

resource "google_service_account" "run_checkout_prod" {
  project      = "rc-saas-prod-app-01"
  account_id   = "sa-run-checkout-prod"
  display_name = "Cloud Run service account for checkout-prod"
}

Pitfall. Applying a Terraform plan that omits ingress recreates the field at its provider default, which is unrestricted — a change with no visible diff in the container image or environment variables can still widen the service's exposure. Pin ingress explicitly in every service resource.

10.3 Jobs §

A Cloud Run job runs a container image to completion across a fixed number of tasks instead of answering requests, and has no invoker IAM surface or ingress setting because nothing calls it over HTTP — it is started by gcloud run jobs execute, a Cloud Scheduler target, or a Workflows/Eventarc trigger. This book names jobs <app>-<task>billing-nightly-close, checkout-report-export.

IMAGE="us-central1-docker.pkg.dev/rc-saas-shared-art-01/billing/nightly-close:2.1.0"
gcloud run jobs create billing-nightly-close \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --image="${IMAGE}" \
  --service-account=sa-run-billing-nightly-close@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --tasks=4 \
  --parallelism=4 \
  --max-retries=2 \
  --task-timeout=15m

Execution requires roles/run.jobsExecutor (or roles/run.jobsExecutorWithOverrides for a principal that must override task count or environment variables at execution time), separate from the roles/run.developer needed to create or update the job definition. A scheduled invoker — Cloud Scheduler's own service account — should hold only jobsExecutor, never run.admin.

gcloud run worker-pools also exists in the current SDK as a resource type for continuously running, pull-based background processing (queue and topic consumers) with no HTTP endpoint at all, splitting instances between revisions rather than traffic percentages. The Cloud Run release notes record the transition: "April 14, 2026 — Support for worker pools is in General Availability (GA)." This chapter still builds its background-processing examples on jobs (§10.28), because jobs are what the rest of the estate's tooling already targets, not because worker pools carry a launch-stage caveat.

Pitfall. --parallelism greater than the downstream system's connection budget. Four parallel tasks each opening a Cloud SQL connection pool can exhaust max_connections on a modest Cloud SQL tier faster than the equivalent request load ever would, because a job has no per-instance concurrency throttle the way a service does.

10.4 Revisions §

Every configuration change to a service — new image, new environment variable, new resource limit — creates a new, immutable revision; the running container of an existing revision is never mutated in place. Revisions are retained automatically, up to a documented cap per service, with the oldest pruned once traffic has moved off them. gcloud run revisions list --service=checkout-prod --region=us-central1 shows the revision history and current traffic share; gcloud run revisions describe REVISION shows the resolved configuration.

Because a revision is immutable, rollback is a traffic operation, not a redeploy: shifting 100% of traffic back to a prior revision with gcloud run services update-traffic (§10.25) is faster and safer than rebuilding and redeploying the old image, and it works even if the Artifact Registry image the old revision used has since been deleted, because the revision already holds its own resolved digest reference.

Pitfall. Assuming a revision name is meaningful; Cloud Run generates a suffix automatically unless --revision-suffix is set. Automating around revision names (for alerting, dashboards) without pinning a suffix produces brittle automation that breaks on the next deploy.

10.5 Containers §

A Cloud Run container is subject to a documented contract, not general container semantics. Google's container-contract documentation states it directly: "The ingress container within an instance must listen for requests on 0.0.0.0 on the port to which requests are sent," and Cloud Run injects a PORT environment variable — 8080 by default — telling the container which port that is; hardcoding a different port without also setting --port to match makes the container unreachable. The filesystem is writable but not durable: "Data written to the file system doesn't persist when the instance stops," and writing to it "uses the instance's memory," so treating /tmp as scratch space for small, short-lived files is fine, and treating it as a cache or a place to buffer uploads at scale is a memory-exhaustion risk with no persistence benefit in return.

A container can define multiple containers in one revision (a sidecar pattern) using --container and --add-volume-mount, useful for a proxy or an adapter process that shares the instance's lifecycle with the main application container, but exactly one container is designated as the ingress container that receives traffic.

Pitfall. Building images FROM a base that runs as root with no USER directive. Cloud Run does not enforce a non-root user, so a container escape or a dependency vulnerability with local privilege implications has more to work with than it needs to; set a non-root USER in the Dockerfile as a baseline control independent of anything Cloud Run configures.

10.6 Autoscaling §

Cloud Run scales the number of container instances for a revision up and down based on incoming request volume, and can scale to zero instances when there is no traffic and no configured minimum. Google's autoscaling documentation frames the trade-off precisely: the system balances "cold start latency (the time to start a new instance) and pending queue latency (the time a request waits for a slot to open on an existing instance)," and states plainly that "on-demand scaling is the only driver for scaling from zero" — nothing else brings an instance up from nothing.

To reduce how often a cold start happens without eliminating it, Cloud Run "might keep instances idle for a period of time after they finish handling requests (up to 15 minutes, or 10 minutes for GPUs)" before reclaiming them, independent of any --min-instances setting. --min-instances (§10.9) is the only setting that keeps instances "permanently available," per the same documentation, rather than merely retained opportunistically.

Security posture. Autoscaling has no upper bound unless --max-instances (§10.10) is set, so an unauthenticated or under-throttled ingress path combined with unbounded autoscaling turns a traffic spike, a retry storm, or a deliberate flood into first a cost incident and then, once a shared downstream resource like a Cloud SQL connection pool saturates, an availability incident for every other consumer of that resource.

10.7 Concurrency §

--concurrency sets the maximum number of simultaneous requests a single container instance will accept before Cloud Run routes the next request to a different (or new) instance; the default is 80 and the maximum is 1,000. Concurrency is a capacity-planning setting with a security dimension: a container that is not thread-safe, or that stores per-request state in a process-global variable, will leak data between concurrent requests on the same instance if concurrency is left above 1 for code that was never designed to be reentrant.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --concurrency=40

Pitfall. Raising concurrency to reduce cost without load-testing the actual per-instance memory footprint at that concurrency. Cloud Run enforces the memory limit (§10.8) per instance, not per request, so N concurrent requests each holding a moderate amount of working memory can OOM-kill the instance well before request volume alone would suggest a problem.

10.8 CPU and Memory §

--cpu and --memory set the resource allocation per container instance (for example --cpu=1 --memory=512Mi), and the combination determines both cost and which CPU allocation model applies. Under the default, request-based billing setting, documented behavior is that "CPU is only allocated during request processing" — background work outside an active request may be throttled to near zero, which is what --cpu-throttling (default enabled) controls explicitly. Switching to instance-based billing allocates CPU "for the entire container instance lifecycle," enabling genuine background work — a queue consumer loop, a periodic flush — at the cost of paying for idle CPU the whole time an instance is warm. --cpu-boost (default enabled on new services) temporarily raises CPU during startup specifically to reduce cold-start latency for CPU-bound initialization.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --cpu=2 --memory=1Gi --no-cpu-throttling

Pitfall. Disabling CPU throttling to enable background work without also reviewing whether that raises the effective cost per instance-hour enough to make --max-instances (§10.10) the binding cost control rather than request volume.

10.9 Minimum Instances §

--min-instances (service-wide) or the revision-scoped --min-instances keeps a floor of warm instances running even with no traffic, trading cost for reduced cold-start latency; it does not guarantee the elimination of cold starts, because a burst above the minimum still starts new instances on demand the same as scaling from zero would.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1 --min=2

It does not eliminate cold starts, and treating it as though it does is the usual mistake. A burst above the floor starts new instances exactly as scaling from zero would; the floor only removes the cold start on the first request after idle.

Pitfall. Warm instances are billed whether or not they serve anything, so a minimum applied across every service in a large estate is a permanent cost with no traffic behind it. Set it on the latency-sensitive public entry points, not as an estate-wide default.

10.10 Maximum Instances §

--max-instances bounds the highest number of concurrent instances a revision (or, set on the service, the service as a whole) will run, and functions as both a cost control and a blast-radius control, not merely a scaling knob.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1 --max=20

Its absence converts an availability attack into a cost attack, and then into an outage somewhere else. Every instance typically opens its own database connections, so an unbounded scale-out exhausts the downstream connection pool (§12.16) and takes down the database for every consumer, not just the service that scaled.

Set --max-instances on every service and job in the estate, sized against the downstream capacity each instance consumes rather than against expected traffic. The number that matters is how many connections the database can survive, not how many requests you expect.

Pitfall. A maximum set too low is a self-inflicted outage under legitimate load, and Cloud Run's behavior at the ceiling is to queue and then fail rather than to degrade gracefully. Set it deliberately from the downstream limit, alert on approaching it, and treat hitting it as a capacity signal.

10.11 Ingress Controls §

--ingress restricts which network paths may reach a Cloud Run service at all, independent of IAM invoker checks (§10.14). The GA enum, confirmed against the installed SDK, has three values: all (default — any source, including the public internet), internal (only VPC networks in the same project or VPC Service Controls perimeter, plus same-project Pub/Sub and Eventarc), and internal-and-cloud-load-balancing (adds Google Cloud Load Balancing as a permitted source on top of everything internal allows).

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --ingress=internal-and-cloud-load-balancing

Ingress and invoker IAM are independent controls, and both must be right: --ingress=all with --no-allow-unauthenticated still requires a valid identity token but accepts the attempt from anywhere on the internet, while --ingress=internal with --allow-unauthenticated accepts any caller reachable on the permitted network paths with no identity check at all. §10.17 covers why internal-and-cloud-load-balancing specifically is the setting that makes a load balancer's Cloud Armor policy non-bypassable.

Organization policy constraints/run.allowedIngress restricts which ingress values a project may set at all, letting a platform team forbid all estate-wide rather than relying on every service owner to choose correctly; see §2.30 for how a constraint like this is evaluated and inherited.

10.12 Public Services §

A Cloud Run service is made public by binding roles/run.invoker to the special principal allUsers, which removes the identity-token requirement entirely. This is one of the very few places in this book where allUsers appears, because most services should never accept unauthenticated traffic directly — a public HTTP surface normally belongs behind the load balancer and Cloud Armor policy built in Chapter 7, with Cloud Run's own ingress restricted to internal-and-cloud-load-balancing (§10.17).

gcloud run services add-iam-policy-binding checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --member=allUsers \
  --role=roles/run.invoker

Warning. Binding allUsers removes Cloud Run's own authentication layer entirely; from that point, the application itself is the only thing standing between the internet and the container, unless the load balancer path in §10.17§10.18 is also in place and the direct *.run.app URL is independently restricted. Organization policy constraints/run.managed.requireInvokerIam exists specifically to make this binding impossible estate-wide, and a platform team running a mixed estate of genuinely public APIs and internal services should apply it at the folder or project level for every project that has no legitimate public Cloud Run service, rather than trusting service-level review alone (§2.30).

10.13 Private Services §

The secure default for a Cloud Run service is no allUsers binding at all: invocation requires roles/run.invoker granted to specific service accounts or groups, and the caller presents a Google-issued identity token that Cloud Run itself verifies before the request reaches the container.

gcloud run services add-iam-policy-binding billing-api-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --member="serviceAccount:sa-run-checkout-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role=roles/run.invoker

Combined with --ingress=internal or internal-and-cloud-load-balancing, a private service has two independent layers: a network-reachability boundary and an identity boundary. Losing either one still leaves the other in place, which is the point of defense in depth here — an ingress misconfiguration does not by itself make the service anonymously callable, and an IAM misconfiguration does not by itself make it reachable from the public internet.

10.14 IAM Authentication §

Cloud Run's IAM invoker check is enabled by default (--allow-unauthenticated must be explicitly set to disable it) and evaluates independently of ingress: every request must carry a valid Google-issued identity token identifying a principal holding roles/run.invoker on that specific service, checked before the request is handed to the container. --no-invoker-iam-check exists to disable this check entirely for a service and should be treated the same as a public allUsers binding from a security standpoint — it removes authentication, not merely relaxes it.

gcloud run services get-iam-policy checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1

reviews the current invoker bindings; gcloud run services remove-iam-policy-binding reverses one. Organization policy constraints/run.managed.requireInvokerIam forces this check on for every service in scope, which is the estate-wide way to prevent --no-invoker-iam-check or an allUsers binding from ever taking effect, rather than relying on catching it in review (§2.30).

Pitfall. Confusing "requires authentication" with "is authorized." A caller holding any valid Google identity but not the invoker role on this specific service is rejected by Cloud Run before the request reaches the application — but a caller holding roles/run.invoker is authenticated and authorized to invoke, with no further application-level authorization implied. Application code still needs its own authorization logic for what an authenticated caller may do.

10.15 Service-to-Service Authentication §

When one Cloud Run service calls another privately (§10.13), the calling service's attached service account requests a Google-signed identity token from the metadata server, scoped to the receiving service's URL as the audience, and presents that token as a bearer credential on the outbound request. The confirmed request against the metadata server is:

curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/\
default/identity?audience=AUDIENCE" -H "Metadata-Flavor: Google"

The ID-token exchange between two private Cloud Run services, in four numbered steps. Step one, the caller service, using its attached service account, requests an identity token from metadata.google.internal with the receiving service's URL as the audience. Step two, a Google-signed ID token is returned. Step three, the caller calls the receiving service with that token in an Authorization Bearer header. Step four, the receiving service verifies the signature and the audience, and IAM checks the invoker role.

with AUDIENCE set to the full URL of the service being called. Client libraries in every supported language wrap this call; application code should use the library's ID-token helper rather than shelling out to curl against the metadata server directly, but the underlying mechanism is the one shown above, and it never involves downloading or storing a service account key.

gcloud run services add-iam-policy-binding billing-api-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --member="serviceAccount:sa-run-checkout-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role=roles/run.invoker

grants the calling service's identity the right to invoke the receiving service; the calling service's own sa-run-checkout-prod account needs no additional permission to fetch its own identity token, since the metadata server serves it to any process running as that attached identity by construction.

10.16 Custom Domains §

Cloud Run domain mappings let a service answer on a custom hostname directly, but the feature has real limits that make it unsuitable as the estate's production path. Domain mapping is only available in a fixed list of regions and Google's own documentation states domain mappings "are not production-ready and are not supported at General Availability." The installed SDK confirms the same posture operationally: gcloud run domain-mappings create is documented as being for "Cloud Run for Anthos," and fully managed domain mappings require the beta command surface, which is not installed and, per this book's rule for Preview/Beta features, must not be a production dependency.

The recommended production path is a global external Application Load Balancer (Chapter 7) with a serverless NEG backend pointing at the Cloud Run service, which gives full control over TLS certificates, URL routing, Cloud CDN, and Cloud Armor — none of which a bare domain mapping provides. §10.17 covers the Cloud Run side of that binding.

Pitfall. Reaching for gcloud run domain-mappings because it looks like the fastest path to a custom domain. It is the least capable and least supported option for a production service in this estate; the load balancer path in §10.17 is one extra resource, not a materially larger effort, and it is the only path that also gets Cloud Armor in front of the service.

10.17 Load Balancer Integration §

Cloud Run attaches to a global external Application Load Balancer as a serverless NEG backend (§7.6), pointing the backend service at the Cloud Run service by name and region rather than at a NEG of individual endpoints. Load balancer object assembly, backend service configuration, and NEG types generally are §7.x territory; the Cloud Run side of the binding is narrow.

gcloud compute network-endpoint-groups create neg-checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --network-endpoint-type=serverless \
  --cloud-run-service=checkout-prod

The security-relevant fact, and the reason §10.11's ingress setting matters here specifically: a Cloud Run service's *.run.app default URL exists independently of any load balancer pointed at it, so setting --ingress=internal-and-cloud-load-balancing (§10.11) is what makes the load balancer's Cloud Armor policy (§10.18) non-bypassable — with ingress left at all, the direct *.run.app URL is a working, unprotected path around every control the load balancer enforces. This is the same failure mode §7.19 documents for serverless backends generally; this section states only that Cloud Run's own ingress setting is the half of the fix that lives on this resource.

10.18 Cloud Armor Integration §

Cloud Armor policies attach to the backend service in front of the serverless NEG (§10.17), not to the Cloud Run service itself — Cloud Run has no native WAF or rate-limiting surface of its own. Policy authoring, preconfigured WAF rules, and rate-limiting configuration are §7.13's subject; the only Cloud Run-specific fact is that the policy protects nothing unless the *.run.app direct path is also closed off.

gcloud compute backend-services update be-checkout-prod \
  --project=rc-saas-prod-app-01 \
  --global \
  --security-policy=armor-checkout-prod

Verify the pairing is actually effective by confirming both sides independently: the backend service reports the attached policy (gcloud compute backend-services describe be-checkout-prod --global --format="value(securityPolicy)"), and the Cloud Run service reports internal-and-cloud-load-balancing ingress (gcloud run services describe checkout-prod --region=us-central1 --format="value(status.observedGeneration)" plus a direct request to the *.run.app URL, which should return a rejection once ingress is restricted). Checking only one side gives false confidence.

10.19 VPC Connectivity §

A Cloud Run service reaches resources with only internal IP addresses — a Cloud SQL private IP, an internal load balancer, a peered network — through one of two mechanisms: Serverless VPC Access connectors (§10.20) or Direct VPC egress (§10.21). Neither is required for a service that only calls public Google APIs or other Cloud Run services over their public/private-service paths; VPC connectivity is specifically for reaching workloads that live inside a VPC network with no public endpoint.

--vpc-egress controls how much outbound traffic is routed through whichever mechanism is configured: private-ranges-only (the default) sends only RFC 1918 and Private Google Access destinations through the VPC path and everything else out to the public internet directly, while all-traffic routes every outbound connection through the VPC path, letting Cloud NAT or a firewall policy on that network govern all egress uniformly. The legacy value all is deprecated in favor of all-traffic and should not appear in new configuration.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --vpc-egress=private-ranges-only

Security posture. all-traffic egress is the correct choice when the estate needs every outbound connection — including calls to public internet APIs — to originate from a known, NAT'd address range that firewall policy and Cloud NAT logging can account for; private-ranges- only is the lower-friction default for services that mostly call public Google APIs and only occasionally need an internal destination.

10.20 Serverless VPC Access §

A Serverless VPC Access connector is a small managed set of VM instances (or a user-provided subnet) in the target VPC network that proxies traffic between Cloud Run and that network, addressed by name rather than by network and subnet directly.

gcloud compute networks vpc-access connectors create conn-app-us-central1 \
  --project=rc-saas-shared-net-01 \
  --region=us-central1 \
  --network=vpc-shared-net \
  --range=10.132.0.0/28 \
  --min-instances=2 \
  --max-instances=10

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --vpc-connector=projects/rc-saas-shared-net-01/locations/us-central1/connectors/\
conn-app-us-central1 \
  --vpc-egress=private-ranges-only

The connector's own subnet (10.132.0.0/28 above) is a distinct allocation from the workload subnet and must not overlap it — plan it explicitly rather than letting it collide with the 10.128.0.0/20 app subnet. Connectors incur their own always-on cost even when idle at the minimum instance count, and add a network hop the traffic must traverse.

When to use it over Direct VPC egress (§10.21). Google's own guidance is specific on one point: when the egress path also goes through Cloud NAT, a connector avoids a cold-start penalty that Direct VPC egress can incur in that configuration — "you might experience cold start delays of 30s or more on instance startup when using Direct VPC egress" with Cloud NAT, and the documented recommendation for that case is "using Serverless VPC Access connectors with Cloud NAT" instead.

10.21 Direct VPC Egress §

Direct VPC egress attaches a Cloud Run revision's network interface straight to a subnet in the target VPC network, with no intermediate connector resource, using --network and --subnet instead of --vpc-connector.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --network=vpc-shared-net \
  --subnet=subnet-app-us-central1 \
  --vpc-egress=private-ranges-only \
  --clear-vpc-connector

The subnet used for Direct VPC egress must be /26 or larger — Cloud Run reserves addresses in blocks of 16 per instance, so a smaller subnet runs out of capacity under a traffic surge well before instance-count limits would otherwise bind. --network and --subnet are present in the GA (non-beta) gcloud run deploy command tree in this SDK, alongside --vpc-connector in the same command group, which is the operational signal this book relies on for calling Direct VPC egress the current, non-Preview networking path for new services. The release notes state it directly: "April 24, 2024 — Support for Direct VPC egress, which lets you send traffic directly to a VPC network with no Serverless VPC Access connector required, is now at general availability (GA)."

Direct VPC egress removes the connector's standing cost and the extra network hop, and is the default choice for a new service in this estate unless the specific Cloud NAT cold-start behavior in §10.20 rules it out for that service.

10.22 Secrets §

Cloud Run consumes secrets stored in Secret Manager, whose resource model — secrets, versions, rotation, replication, and IAM — is owned by Chapter 13 (§13.2, §13.3) and not restated here. On the Cloud Run side, a secret can be exposed two ways: as an environment variable via --set-secrets=KEY=SECRET:VERSION, or as a mounted volume via --add-volume with a secret-typed source, which is preferable for a value an application would otherwise need to reread on rotation, since a mounted volume can reflect an updated version without a restart in a way an environment variable cannot.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --update-secrets=DB_PASSWORD=checkout-db-password:3

gcloud secrets add-iam-policy-binding checkout-db-password \
  --project=rc-saas-prod-app-01 \
  --member="serviceAccount:sa-run-checkout-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role=roles/secretmanager.secretAccessor

The service's own service account needs roles/secretmanager.secretAccessor on each secret it reads — granted once per secret, not once per project — and that grant is the entire IAM surface this section adds beyond what Chapter 13 already covers.

Pitfall. Referencing a secret as latest in --set-secrets. The version is resolved once, when the revision starts, and held for the life of that revision; rotating the secret's value in Secret Manager does not change what an already-running revision sees, so a compromised credential referenced as latest keeps working in every running instance until a new revision is explicitly deployed. Pin an explicit version number in production (DB_PASSWORD=checkout-db-password:3, not :latest) so a rotation is only complete, and only takes effect, when you deploy the revision that references the new version — making rotation an auditable deploy event rather than a change that silently either does or does not propagate depending on instance restart timing.

10.23 Cloud SQL Connectivity §

Cloud Run connects to Cloud SQL through the same Cloud SQL Auth Proxy mechanism Chapter 12 documents at §12.17 — mutual TLS and IAM-based authorization to the instance, with no need to manage certificates or allowlist IP ranges by hand — built into the platform rather than run as a separate process you deploy.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --add-cloudsql-instances=rc-saas-prod-app-01:us-central1:rc-saas-prod-sql-01

gcloud projects add-iam-policy-binding rc-saas-prod-app-01 \
  --member="serviceAccount:sa-run-checkout-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role=roles/cloudsql.client

--add-cloudsql-instances provisions a Unix socket inside the container at /cloudsql/INSTANCE_CONNECTION_NAME (here, /cloudsql/rc-saas-prod-app-01:us-central1:rc-saas-prod-sql-01); the application connects to that socket path instead of a TCP host and port. The service's account needs roles/cloudsql.client in addition to the database-level grant a database user would otherwise need.

For a production service already on Direct VPC egress (§10.21), connecting over the instance's private IP directly is the better path: it avoids the built-in connector's per-instance connection overhead, keeps all database traffic on the VPC network where firewall policy and flow logs already apply, and needs no --add-cloudsql-instances binding at all — only network reachability to the Cloud SQL private IP and the same roles/cloudsql.client-adjacent database IAM authentication path §12.17 describes.

10.24 Observability §

Cloud Run services and jobs emit structured request and container logs to Cloud Logging and metrics to Cloud Monitoring automatically, with no agent to install. Request logs capture the path, status code, latency, and the calling identity when IAM authentication is in effect; container stdout/stderr output is captured as log entries without any logging library configuration required, though writing structured JSON to stdout lets Cloud Logging parse fields automatically.

gcloud logging read \
  'resource.type="cloud_run_revision" AND resource.labels.service_name="checkout-prod"' \
  --project=rc-saas-prod-app-01 \
  --limit=50

Security relevance. Request logs are the primary forensic record for who invoked a service and when, particularly for a private service where the caller's service account identity is recorded on every request — retain them for at least the estate's incident-response window and route them to the centralized logging project rather than leaving them only in the per-service project's default log bucket, consistent with the logging-project pattern in §2.22.

Pitfall. Logging request or response bodies containing customer data at INFO level by default. Cloud Run does not redact application-level log content, so the application itself is responsible for not writing sensitive fields to stdout; Sensitive Data Protection (Chapter 15) can scan exported logs but does not prevent the write in the first place.

10.25 Deployment Strategies §

Because every configuration change produces a new immutable revision (§10.4) and traffic is assigned to revisions independently of deployment, Cloud Run supports canary, blue/green, and instant-rollback strategies natively through gcloud run services update-traffic, without any external traffic-management layer.

# Deploy a new revision with no traffic yet, tagged for direct testing.
gcloud run deploy checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1 \
  --image="${IMAGE}" --no-traffic --tag=canary

# Shift 10% of live traffic to the tagged revision.
gcloud run services update-traffic checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1 \
  --to-tags=canary=10

# Once verified, promote to 100% and retire the split.
gcloud run services update-traffic checkout-prod \
  --project=rc-saas-prod-app-01 --region=us-central1 \
  --to-latest

A tagged revision also receives its own stable URL (canary---checkout-prod-HASH.a.run.app in the default domain), which lets automated smoke tests or a small internal audience reach the new revision directly before any percentage of live traffic is shifted to it — useful for testing an IAM or ingress change in isolation before it affects the whole service.

Pitfall. Rolling back by redeploying the previous image tag rather than shifting traffic back to the previous revision. A redeploy creates yet another new revision — reintroducing whatever race or cold-start behavior a fresh revision has — where --to-revisions or --to-latest against an already-warm prior revision is both faster and avoids that risk entirely.

10.26 Cloud Run Security §

Cloud Run's security posture rests on five independent layers. Each fails closed on its own, and the estate is only protected when all five are configured together:

LayerQuestion it answersOwned by
Container imageIs this code trustworthy?Minimal non-root base, scanned in Artifact Registry (Chapter 24, Chapter 37)
Service accountWhat may this code do?sa-run-<service>, dedicated, never the default compute account
IngressWhat may reach it?§10.11
Invoker IAMWho may call it?§10.14
EgressWhat may it reach outbound?§10.19§10.21

The layers are independent, which is the design. An ingress misconfiguration does not by itself make a service anonymously callable, and an IAM mistake does not by itself expose it to the internet. A posture review that checks one and assumes the rest is checking nothing.

Binary Authorization integrates at deploy time: --binary-authorization=default requires every image deployed to a service to satisfy the project's attestation policy before Cloud Run will run it, refusing an unsigned or unverified image regardless of who has run.developer and however much they are otherwise authorized to deploy.

gcloud run services update checkout-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --binary-authorization=default

Organization policy constraints/run.allowedBinaryAuthorizationPolicies lets a platform team require this estate-wide rather than trusting each service owner to enable it, and constraints/run.allowedVPCEgress similarly restricts which --vpc-egress values a project may choose, forbidding all-traffic where it is not justified. §2.30 covers how these constraints are evaluated and inherited; both are always written with the constraints/ prefix.

Customer-managed encryption of a Cloud Run service's data at rest is a per-service wiring of the --key flag against a Cloud KMS key in rc-saas-shared-sec-01, with the grant of roles/cloudkms.cryptoKeyEncrypterDecrypter to the Cloud Run service agent; the key ring, key protection level, and rotation policy are Chapter 14's subject (§14.8), not restated here.

Pitfall. The default compute service account is attached to a Cloud Run service whenever --service-account is omitted, and it is omitted by default. A service deployed without that flag runs with broad project-level permissions that nobody chose and no review surfaced, because nothing in the deploy command mentions an identity at all.

Least-privilege attestation for this book's reference deployment: sa-run-checkout-prod holds roles/secretmanager.secretAccessor on exactly the secrets it reads, roles/cloudsql.client if it connects to Cloud SQL, and nothing project-wide — no roles/run.admin, no basic role, and no role granted "just in case."

10.27 Cloud Run for APIs §

An internal or partner-facing API on Cloud Run in this estate is deployed private by default: --ingress=internal-and-cloud-load-balancing, no allUsers invoker binding, and a global external Application Load Balancer in front with a serverless NEG (§10.17) terminating TLS and carrying the Cloud Armor policy (§10.18). Per-consumer authentication, API keys for quota attribution, and OpenAPI-driven request validation belong to Chapter 21's API Gateway and Apigee material (§21.1, §21.9), not to Cloud Run itself — Cloud Run's own IAM invoker check is a service-to-service and administrative control, not a per-external-consumer one.

gcloud run deploy billing-api-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --image="us-central1-docker.pkg.dev/rc-saas-shared-art-01/billing/api:3.2.0" \
  --service-account=sa-run-billing-api-prod@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --no-allow-unauthenticated \
  --ingress=internal-and-cloud-load-balancing \
  --binary-authorization=default \
  --min-instances=1 \
  --max-instances=30 \
  --concurrency=60

The service account for an API service typically holds roles/secretmanager.secretAccessor for its signing keys or upstream credentials and roles/cloudsql.client for its data store, and invocation of other internal services is granted per-caller with roles/run.invoker (§10.15) rather than left open within the project.

10.28 Cloud Run for Background Jobs §

The estate's background processing — nightly reconciliation, report generation, batch data export — runs as Cloud Run jobs (§10.3) rather than as an always-on worker, because a job's cost and blast radius are both bounded by its task count and timeout rather than by ambient traffic.

gcloud run jobs create billing-nightly-close \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --image="us-central1-docker.pkg.dev/rc-saas-shared-art-01/billing/nightly-close:2.1.0" \
  --service-account=sa-run-billing-nightly-close@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --set-secrets=DB_PASSWORD=checkout-db-password:3 \
  --set-cloudsql-instances=rc-saas-prod-app-01:us-central1:rc-saas-prod-sql-01 \
  --tasks=1 \
  --max-retries=1 \
  --task-timeout=30m

Cloud Scheduler triggers execution on a cron schedule using an OIDC token scoped to roles/run.jobsExecutor on this job only, which is the entire IAM surface the scheduler's own service account needs — it does not need run.developer, and it never needs to be able to alter the job's own image or configuration.

gcloud scheduler jobs create http billing-nightly-close-trigger \
  --project=rc-saas-prod-app-01 \
  --location=us-central1 \
  --schedule="0 2 * * *" \
  --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/\
rc-saas-prod-app-01/jobs/billing-nightly-close:run" \
  --oauth-service-account-email=sa-scheduler-billing@rc-saas-prod-app-01.iam.gserviceaccount.com

Pitfall. Granting the scheduler's service account run.developer "to simplify setup." That account only ever needs to trigger one job's execution, and jobsExecutor scoped to that job is the entire permission a compromised scheduler credential should be able to exercise.

10.29 Cloud Run for SaaS §

A multi-tenant SaaS surface on Cloud Run separates tenant-facing services from internal, platform-only services along the same ingress and invoker boundary the rest of this chapter uses, rather than inventing a separate tenancy mechanism at the Cloud Run layer. Tenant-facing services (checkout-prod, billing-api-prod) sit behind the load balancer with Cloud Armor and per-tenant rate limiting (§7.15); platform-internal services (a tenant-provisioning worker, an internal admin API) are deployed with --ingress=internal and no load balancer at all, callable only by other services in the same project holding the appropriate roles/run.invoker grant.

gcloud run deploy tenant-provisioner-prod \
  --project=rc-saas-prod-app-01 \
  --region=us-central1 \
  --image="us-central1-docker.pkg.dev/rc-saas-shared-art-01/platform/tenant-provisioner:1.0.2" \
  --service-account=sa-run-tenant-provisioner-prod@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --no-allow-unauthenticated \
  --ingress=internal \
  --max-instances=5

Tenant data isolation itself — separate databases, separate schemas, or row-level security — is a data-layer decision (Chapter 12) independent of Cloud Run; the platform's role here is to ensure that only the services authorized to cross tenant boundaries (the provisioning worker, a migration job) hold the invoker grants and database credentials that let them, and that every tenant-facing service runs under a service account scoped tightly enough that a vulnerability in one tenant-facing service cannot reach another tenant's data through an over-broad IAM grant.

Chapter Summary §

  • Cloud Run runs stateless containers as services (request-driven, autoscaling to zero) or jobs (run-to-completion); both are regional, and every configuration change produces a new immutable revision.
  • The container contract requires listening on 0.0.0.0:$PORT; the local filesystem is writable but backed by instance memory and never persists across instance stop.
  • CPU is only allocated during request processing under the default billing setting; instance- based billing allocates CPU for the whole instance lifecycle, which is what genuine background work inside a container requires.
  • --min-instances reduces cold starts but does not eliminate them; --max-instances bounds cost and blast radius and its absence turns a traffic spike into an unbounded bill and a saturated downstream connection pool.
  • Ingress (all / internal / internal-and-cloud-load-balancing) and invoker IAM (roles/run.invoker) are independent controls; both must be set correctly, and neither implies the other.
  • allUsers bound to roles/run.invoker is the only way to make a service public in this book, and constraints/run.managed.requireInvokerIam is the estate-wide control against it.
  • A serverless NEG backend plus --ingress=internal-and-cloud-load-balancing is what makes a load balancer's Cloud Armor policy non-bypassable; otherwise the *.run.app URL is a direct path around it.
  • Cloud Run domain mappings are not production-ready at General Availability and are region- limited; the production path for a custom domain is a global external Application Load Balancer.
  • Direct VPC egress needs a /26 or larger subnet and removes connector cost and an extra hop; Serverless VPC Access connectors remain the documented choice when the egress path also traverses Cloud NAT, due to a documented cold-start penalty.
  • Secrets referenced as latest resolve at revision start; rotating the secret does not change a running revision, so production should pin an explicit version and roll a new revision to rotate.
  • Cloud Run's built-in Cloud SQL connectivity mounts a Unix socket at /cloudsql/INSTANCE_CONNECTION_NAME; private IP over Direct VPC egress is the better path for a production service already off the connector.
  • Service-to-service calls fetch a Google-signed ID token from the metadata server, scoped by audience to the receiving service's URL, with no service account key involved.
  • The default compute service account is never attached to a Cloud Run resource in this book; a dedicated sa-run-<service> account holding only the roles that service needs is the standard.

Security Checklist §

ControlWhy it mattersHow to verify
No allUsers binding on roles/run.invoker outside a service explicitly meant to be publicRemoves authentication for the entire servicegcloud run services get-iam-policy SERVICE --region=REGION
constraints/run.managed.requireInvokerIam applied where no service should ever be publicPrevents allUsers and --no-invoker-iam-check estate-widegcloud org-policies describe constraints/run.managed.requireInvokerIam --project=PROJECT
Ingress restricted to internal-and-cloud-load-balancing for every load-balanced serviceOtherwise the *.run.app URL bypasses the load balancer and Cloud Armor entirelygcloud run services describe SERVICE --region=REGION --format="value(spec.template.metadata.annotations)"; test the direct URL
Dedicated sa-run-<service> account per service, never the default compute service accountLimits blast radius of a compromised container to that service's own permissionsgcloud run services describe SERVICE --region=REGION --format="value(spec.template.spec.serviceAccountName)"
Secrets referenced by pinned version, never :latest, in productionA rotation does not reach a running revision until redeployed; pinning makes rotation an explicit, auditable stepgcloud run services describe SERVICE --region=REGION --format="value(spec.template.spec.containers[].env)"
--max-instances set on every service and jobBounds cost and downstream connection-pool exhaustion during a spike or retry stormgcloud run services describe SERVICE --region=REGION --format="value(spec.template.metadata.annotations)"
--binary-authorization=default enabled where the estate requires attestationRefuses an unsigned or unverified image regardless of deployer permissionsgcloud run services describe SERVICE --region=REGION --format="value(spec.template.spec.template.metadata.annotations)"
constraints/run.allowedIngress restricts all where not justifiedPrevents a service owner from widening ingress without platform reviewgcloud org-policies describe constraints/run.allowedIngress --project=PROJECT
Cloud SQL connections use roles/cloudsql.client scoped to the service account, not a broader IAM grantLimits which identities can open a proxied database connectiongcloud projects get-iam-policy PROJECT --flatten="bindings[].members" --filter="bindings.role:roles/cloudsql.client"
Scheduler and other automation accounts hold only roles/run.jobsExecutor on the specific job, not roles/run.developerA compromised trigger credential cannot alter the job it triggersgcloud projects get-iam-policy PROJECT --flatten="bindings[].members" --filter="bindings.role:roles/run.jobsExecutor"

Sources §