
Production Cloud Run Service with Terraform: Custom Domain, IAM, VPC Connector & CI/CD | 2026
A production Cloud Run service in Terraform is five resources done right: `google_cloud_run_v2_service` with a dedicated service account and explicit scaling limits, a Secret Manager-backed environment, a Serverless VPC Access connector (or Direct VPC egress) for private Cloud SQL and Memorystore, a least-privilege `roles/run.invoker` binding instead of allUsers, a `google_cloud_run_domain_mapping` for the custom domain, and a Cloud Build trigger that only updates the image so Terraform stays the source of truth for everything else.
By Mateusz Chmielewski · Aug 21, 2026 · 16 min read
What Is a Production Cloud Run Service in Terraform?
Cloud Run runs stateless containers on Google's serverless platform with per-request autoscaling from zero to thousands of instances. A production-grade deployment is more than `gcloud run deploy`: it is a `google_cloud_run_v2_service` resource with pinned concurrency and instance limits, its own service account, private connectivity into your VPC, secrets mounted from Secret Manager, a custom domain with a Google-managed certificate, and a CI/CD pipeline — all expressed as Terraform so every change is reviewed, planned, and reversible.
Think of Cloud Run like a fully staffed food truck that appears the moment a customer walks up — but production Terraform is the franchise contract: it fixes who is allowed to sell (IAM invoker), which suppliers the truck may call privately (VPC connector), where the secret recipes are stored (Secret Manager), the branded street address (custom domain), and the rule that only the supply office may repaint the truck while the kitchen may swap the menu daily (Terraform owns config, CI owns the image).
| Concept | Explanation | When to use |
|---|---|---|
| google_cloud_run_v2_service | The Terraform resource for a Cloud Run service — template, scaling, ingress, service account, and container spec in one place. | Always — the v2 resource is the current API surface; the v1 google_cloud_run_service is legacy. |
| Custom domain mapping | Binds api.example.com directly to the service with a Google-managed TLS certificate — no load balancer required. | For user-facing HTTPS endpoints that do not need Cloud Armor or Cloud CDN. |
| Serverless VPC Access connector | A managed bridge (2+ e2-micro instances) that lets Cloud Run reach VPC-internal IPs such as Cloud SQL private IP and Memorystore. | When the service talks to private resources; consider Direct VPC egress instead to skip connector cost. |
| run.invoker IAM | The single role that permits calling the service; without it a caller gets HTTP 403 even with a valid identity. | On every service — grant it to specific callers and omit allUsers unless the API is deliberately public. |
| Cloud Build trigger | Fires on git push, builds the container, pushes to Artifact Registry, and updates the running service. | For every service — manual deploys from laptops do not survive an audit. |
Why Define Cloud Run Production Infrastructure in Terraform?
The default path — clicking Deploy in the console or running gcloud run deploy from a laptop — produces services that share the mighty default compute service account, allow unauthenticated access because a checkbox was ticked once, hold database passwords in plain env vars, cannot reach Cloud SQL private IP at all, and drift the moment a teammate hot-fixes a flag in the console. None of that is visible in a review, and none of it is reproducible in a second project.
Terraform turns the service into reviewed, versioned code: a dedicated least-privilege service account, an explicit invoker binding, secrets referenced from Secret Manager (see [GCP Secret Manager Auto-Rotation Terraform Module](/tutorial/gcp-secret-manager-terraform-module-automatic-rotation) for the rotation pattern), private egress through a VPC connector — and if you also need a stable outbound IP, see [Route Cloud Run & GKE Egress Through a Static IP with Terraform](/tutorial/serverless-egress-static-ip-terraform). A Cloud Build trigger then updates only the container image, so Terraform remains the single source of truth for configuration.
| Feature | thisService | altA | altB |
|---|---|---|---|
| Configuration source of truth | Terraform state — every flag reviewed in a PR | Console/gcloud (snowflake services, invisible drift) | GKE + Terraform (full control, far more machinery) |
| Ops burden | None — Google runs, scales, and patches instances | None, but no audit trail or reproducibility | Nodes, upgrades, autoscaling policies are on you |
| Scale to zero / cold start | Native; min instances eliminate cold starts | Same platform, unmanaged lifecycle | Possible via HPA to zero, but node floor keeps billing |
| Private VPC access | VPC connector or Direct VPC egress in code | Manual connector clicks, easy to misconfigure | Native pod networking, but you manage the cluster |
| Billing model | Per-request, 100ms granularity (1ms with CPU always allocated) | Same pricing, unpredictable without instance limits | Per-node 24/7 regardless of traffic |
Prerequisites
- GCP project with billing enabled and permission to enable APIs
- Terraform v1.6+ and the google/google-beta provider ~> 6.0
- gcloud CLI v480.0+ installed and authenticated
- roles/run.admin, roles/iam.serviceAccountAdmin, roles/vpcaccess.admin, roles/cloudbuild.builds.editor, and roles/secretmanager.admin on the project (or equivalent)
- A domain you control (for the mapping) and a VPC with Cloud SQL or Memorystore on private IP
Step-by-Step Guide
Step 1: Enable APIs and Pin the Terraform Backend
Enable the five APIs the stack depends on and configure a GCS backend so the state file is shared, versioned, and locked — Cloud Run, Serverless VPC Access, Secret Manager, Artifact Registry, and Cloud Build. A local state file on one laptop is a single point of failure for production. The GCS backend with state locking prevents two engineers (or CI and an engineer) from corrupting state with concurrent applies.
gcloud services enable run.googleapis.com \
vpcaccess.googleapis.com \
secretmanager.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
--project=$PROJECT_ID
# backend.tf
terraform {
required_version = ">= 1.6"
backend "gcs" {
bucket = "acme-tf-state"
prefix = "cloud-run/payments-api"
}
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
Step 2: Create the Runtime Service Account and Artifact Registry Repo
Provision a dedicated service account that the Cloud Run revision will run as, grant it only the roles it needs (Cloud SQL client, Secret Manager accessor), and create the Docker repository CI will push images to. The default compute service account is an Editor on the whole project — one compromised container is a project-wide breach. A per-service account shrinks blast radius to exactly the secrets and databases that service touches.
resource "google_service_account" "run_sa" {
account_id = "payments-api-run"
display_name = "Cloud Run runtime identity for payments-api"
}
resource "google_project_iam_member" "run_sql_client" {
project = var.project_id
role = "roles/cloudsql.client"
member = "serviceAccount:${google_service_account.run_sa.email}"
}
resource "google_artifact_registry_repository" "apps" {
location = var.region
repository_id = "apps"
format = "DOCKER"
cleanup_policies {
id = "keep-last-10"
action = "KEEP"
most_recent_versions { keep_count = 10 }
}
}
Step 3: Define the Cloud Run Service with Scaling Guardrails
Declare the google_cloud_run_v2_service with the runtime identity, concurrency, min/max instance bounds, startup CPU boost, and a startup probe — the core resource every other step attaches to. max_instance_count is your hard cost ceiling against retry storms and traffic spikes; min_instance_count of 1 removes cold starts for latency-sensitive APIs; concurrency (default 80, max 1000) decides how much traffic one instance absorbs before a new one spins up.
resource "google_cloud_run_v2_service" "api" {
name = "payments-api"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.run_sa.email
max_instance_request_concurrency = 200
timeout = "300s"
scaling {
min_instance_count = 1
max_instance_count = 20
}
containers {
image = "${var.region}-docker.pkg.dev/${var.project_id}/apps/payments-api:1.4.2"
ports { container_port = 8080 }
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true
startup_cpu_boost = true
}
startup_probe {
http_get {
path = "/healthz"
port = 8080
}
initial_delay_seconds = 5
period_seconds = 10
failure_threshold = 3
}
}
}
}
Step 4: Inject Secrets from Secret Manager
Create the database password secret, grant the runtime service account accessor rights on that one secret, and reference it natively in the container spec with secret_key_ref — the value never appears in Terraform state outputs or env var plaintext. A database password in a plain env var is visible to anyone with run.services.get — which includes every viewer on the project. Secret Manager references keep the value out of the service spec entirely and give you versioning, rotation, and audit logs for free.
resource "google_secret_manager_secret" "db_password" {
secret_id = "payments-api-db-password"
replication { auto {} }
}
resource "google_secret_manager_secret_iam_member" "db_password_access" {
secret_id = google_secret_manager_secret.db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.run_sa.email}"
}
# inside google_cloud_run_v2_service.api template.containers:
env {
name = "DB_PASSWORD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_password.secret_id
version = "latest"
}
}
}
Step 5: Attach the VPC Connector for Private Cloud SQL Access
Create a Serverless VPC Access connector on a dedicated /28 range and wire it into the service template with PRIVATE_RANGES_ONLY egress, so Cloud SQL private IP and Memorystore are reachable while internet egress keeps its default path. Without VPC access, Cloud Run cannot reach RFC 1918 addresses at all — teams then expose Cloud SQL on a public IP as a workaround. PRIVATE_RANGES_ONLY keeps only internal traffic inside the VPC; ALL_TRAFFIC would route internet egress through your NAT too, doubling connector load.
resource "google_vpc_access_connector" "serverless" {
name = "run-vpc-connector"
region = var.region
network = google_compute_network.vpc.name
ip_cidr_range = "10.8.0.0/28"
min_instances = 2
max_instances = 3
machine_type = "e2-micro"
}
# inside google_cloud_run_v2_service.api template:
vpc_access {
connector = google_vpc_access_connector.serverless.id
egress = "PRIVATE_RANGES_ONLY"
}
# Alternative with no connector cost — Direct VPC egress:
# vpc_access {
# network_interfaces {
# network = google_compute_network.vpc.id
# subnetwork = google_compute_subnetwork.serverless.id
# }
# egress = "PRIVATE_RANGES_ONLY"
# }
Step 6: Lock Down Invocation with Least-Privilege IAM
Bind roles/run.invoker to exactly the identities allowed to call the service — a frontend service account here — and deliberately do not grant allUsers, so every request must carry a valid Google-signed identity token. By default gcloud run deploy prompts to allow unauthenticated access, and one yes turns a payroll or admin API into a public endpoint. With no allUsers binding, unauthenticated requests get HTTP 403 before they ever reach your container.
resource "google_service_account" "frontend_sa" {
account_id = "frontend-caller"
}
resource "google_cloud_run_v2_service_iam_member" "invoker" {
project = var.project_id
location = var.region
name = google_cloud_run_v2_service.api.name
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.frontend_sa.email}"
}
# Caller side: fetch an ID token and call the service
# curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
# https://payments-api-abc123-ew.a.run.app/healthz
Step 7: Map the Custom Domain
Create a google_cloud_run_domain_mapping for api.example.com, then publish the DNS records Terraform outputs so Google can provision the managed certificate — no load balancer, no certificate renewals to manage. Domain mappings give you HTTPS on your own domain with a Google-managed cert in about 15 minutes. The trade-off is that traffic hits Cloud Run directly — if you later need Cloud Armor WAF rules or Cloud CDN, you must front the service with a global external HTTPS load balancer and a serverless NEG instead.
resource "google_cloud_run_domain_mapping" "api" {
location = var.region
name = "api.example.com"
metadata {
namespace = var.project_id
}
spec {
route_name = google_cloud_run_v2_service.api.name
}
}
output "domain_mapping_dns" {
value = google_cloud_run_domain_mapping.api.status[0].resource_records
}
Step 8: Automate Builds with a Cloud Build Trigger
Wire a Cloud Build trigger on pushes to main that builds the image, pushes it to Artifact Registry tagged with the commit SHA, and updates the service image — while granting the Cloud Build service account exactly the two roles that requires. The pipeline uses gcloud run services update-image, not gcloud run deploy — deploy would overwrite Terraform-managed fields (scaling, IAM hints, VPC connector) and recreate the drift this article eliminates. Image-only updates keep Terraform the owner of configuration and CI the owner of the artifact.
resource "google_cloudbuild_trigger" "api_deploy" {
name = "payments-api-deploy-main"
github {
owner = "your-github-org"
name = "payments-api"
push {
branch = "^main
chmielewski.dev | GCP Engineering & Architecture Blueprints
Insights, guides, and architectural blueprints for creators shipping on Google Cloud Platform.
quot;
}
}
filename = "cloudbuild.yaml"
}
resource "google_project_iam_member" "cloudbuild_run_admin" {
project = var.project_id
role = "roles/run.admin"
member = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
}
resource "google_service_account_iam_member" "cloudbuild_act_as" {
service_account_id = google_service_account.run_sa.name
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
}
Verification & Health Check
Best Practices
- Dedicated Runtime Service Account per Service
- No allUsers Unless the API Is Deliberately Public
- Secrets from Secret Manager, Never Plain Env
- Always Set max_instance_count as a Cost Ceiling
Common Mistakes
- {"errorCode":"HTTP_403_FORBIDDEN","symptoms":"Callers receive \"Error 403 Forbidden — your client does not have permission to get URL\" even with valid credentials.","rootCause":"No roles/run.invoker binding for the caller — or the identity token's audience does not match the service URL being called.","fixCommand":"gcloud run services add-iam-policy-binding payments-api \\\n --region=europe-west1 \\\n --member=\"serviceAccount:frontend-caller@PROJECT_ID.iam.gserviceaccount.com\" \\\n --role=\"roles/run.invoker\"\n","code":"resource \"google_cloud_run_v2_service_iam_member\" \"invoker\" {\n project = var.project_id\n location = var.region\n name = google_cloud_run_v2_service.api.name\n role = \"roles/run.invoker\"\n member = \"serviceAccount:${google_service_account.frontend_sa.email}\"\n}\n","language":"hcl","filename":"fix-invoker.tf","prevention":"Declare invoker bindings in Terraform next to the service itself, and test authenticated and unauthenticated curl calls in CI after every deploy."}
- {"errorCode":"CLOUD_SQL_PRIVATE_IP_UNREACHABLE","symptoms":"Container starts but every query fails with connection timeout; logs show dial tcp 10.60.0.3:5432: i/o timeout.","rootCause":"The service has no vpc_access block, so RFC 1918 addresses are unreachable — or egress is ALL_TRAFFIC without a NAT path and even internet calls now fail.","fixCommand":"gcloud run services describe payments-api --region=europe-west1 \\\n --format='value(spec.template.spec.vpcAccess)'\n","code":"vpc_access {\n connector = google_vpc_access_connector.serverless.id\n egress = \"PRIVATE_RANGES_ONLY\" # not ALL_TRAFFIC\n}\n","language":"hcl","filename":"fix-vpc.tf","prevention":"Require a vpc_access block in the module for any service with a Cloud SQL or Memorystore dependency, and smoke-test a DB query in the pipeline's post-deploy step."}
- {"errorCode":"DOMAIN_MAPPING_CERT_STUCK","symptoms":"Custom domain mapping stays in \"certificate provisioning\" for hours; https://api.example.com serves a certificate error or nothing at all.","rootCause":"DNS records do not match the mapping's resource_records output, the domain is not verified for the deploying account, or a conflicting CNAME exists at the mapped hostname.","fixCommand":"gcloud beta run domain-mappings describe api.example.com \\\n --region=europe-west1 \\\n --format='yaml(status.resourceRecords, status.conditions)'\n","code":"output \"domain_mapping_dns\" {\n value = google_cloud_run_domain_mapping.api.status[0].resource_records\n}\n# Create exactly these A/AAAA records at your DNS provider,\n# then wait for propagation (usually < 15 minutes).\n","language":"hcl","filename":"fix-domain.tf","prevention":"Automate DNS record creation in the same Terraform run (google_dns_record_set against your Cloud DNS zone) so the mapping and its records can never drift."}
- {"errorCode":"CLOUDBUILD_DEPLOY_PERMISSION_DENIED","symptoms":"Build succeeds, but the final deploy step fails with \"PERMISSION_DENIED: Permission 'run.services.update' denied\" or \"User does not have iam.serviceaccounts.actAs\".","rootCause":"The Cloud Build service account lacks roles/run.admin, or lacks roles/iam.serviceAccountUser on the runtime service account the revision must run as.","fixCommand":"gcloud projects add-iam-policy-binding $PROJECT_ID \\\n --member=\"serviceAccount:[email protected]\" \\\n --role=\"roles/run.admin\"\n","code":"resource \"google_service_account_iam_member\" \"cloudbuild_act_as\" {\n service_account_id = google_service_account.run_sa.name\n role = \"roles/iam.serviceAccountUser\"\n member = \"serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com\"\n}\n","language":"hcl","filename":"fix-cloudbuild-iam.tf","prevention":"Keep the Cloud Build IAM bindings in the same Terraform module as the trigger, and note that newer projects default builds to the compute service account — grant whichever identity actually runs the build."}
Cost Analysis
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| vCPU-seconds (1 vCPU × 300ms/req, $0.000024/vCPU-s) | $0.00 (free tier) | $0.00 (free tier) | $0.00 (free tier) | ~$2.88 (300K vCPU-s − 180K free) | |
| Memory GiB-seconds (512Mi × 300ms/req) | $0.00 (free tier) | $0.00 (free tier) | $0.00 (free tier) | $0.00 (150K GiB-s < 360K free) | |
| Requests (first 2M/mo free, then $0.40/M) | $0.00 | $0.00 | $0.00 | $0.00 | |
| 1 minimum instance (idle ~730h, CPU billed at reduced idle rate) | ~$35.00 | ~$35.00 | ~$35.00 | ~$35.00 | |
| VPC connector (2× e2-micro, 24/7; $0 with Direct VPC egress) | ~$14.50 | ~$14.50 | ~$14.50 | ~$14.50 | |
| Custom domain mapping + managed certificate | $0.00 | $0.00 | $0.00 | $0.00 | |
| Artifact Registry storage (~2 GB of images, $0.10/GB) | ~$0.20 | ~$0.20 | ~$0.20 | ~$0.20 |
References
- Cloud Run services — deploying and configuring (google_cloud_run_v2_service)
- Map custom domains to Cloud Run services
- About concurrency in Cloud Run (up to 1,000 requests per instance)
- Connect to a VPC network — Serverless VPC Access and Direct VPC egress
- Cloud Run pricing and free tier
- Automate builds with Cloud Build triggers