Home / Serverless

Production Cloud Run Service with Terraform: Custom Domain, IAM, VPC Connector & CI/CD | 2026

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).

ConceptExplanationWhen to use
google_cloud_run_v2_serviceThe 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 mappingBinds 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 connectorA 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 IAMThe 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 triggerFires 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.

FeaturethisServicealtAaltB
Configuration source of truthTerraform state — every flag reviewed in a PRConsole/gcloud (snowflake services, invisible drift)GKE + Terraform (full control, far more machinery)
Ops burdenNone — Google runs, scales, and patches instancesNone, but no audit trail or reproducibilityNodes, upgrades, autoscaling policies are on you
Scale to zero / cold startNative; min instances eliminate cold startsSame platform, unmanaged lifecyclePossible via HPA to zero, but node floor keeps billing
Private VPC accessVPC connector or Direct VPC egress in codeManual connector clicks, easy to misconfigureNative pod networking, but you manage the cluster
Billing modelPer-request, 100ms granularity (1ms with CPU always allocated)Same pricing, unpredictable without instance limitsPer-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

Featuremetriccost1kcost10kcost100kcost1m
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

Browse all tutorials