Table of Contents
Background and Problem
When you publish an internal tool or a staging admin screen on Cloud Run, everyone who knows the URL can reach it. You could implement authentication yourself in the app, but there are plenty of cases where you would rather not bring authorization logic into the application code.
This is where Cloud IAP (Identity-Aware Proxy) helps. Placing IAP in front of the load balancer lets you achieve a private deployment — "only the Google accounts you designate can sign in and reach the service" — without modifying the app at all. Its biggest advantage is that it moves authentication and authorization down into the infrastructure layer.
The key point is that simply assigning a custom domain to Cloud Run is not enough. Enabling IAP presupposes an organization account that owns a custom domain — that is, registration with Google Workspace / Cloud Identity, and the creation of a Google Cloud organization under that account. There are many steps with complex dependencies, so it is safest to grasp the whole picture before you start.
The Big Picture and Prerequisites
The final architecture takes the following flow:
- User → custom domain (DNS) → external HTTPS load balancer → IAP authentication → Cloud Run
To make this work, we assemble the following in order. Note that it includes paid registrations (domain, Workspace, Cloud Identity).
Steps
- Acquire a custom domain (paid)
Acquire the custom domain to use with IAP from a registrar such as a domain registrar of your choice. It becomes the starting point for organization and DNS verification.
- Register with Google Workspace (paid)
Register the acquired domain so it can be treated as an organization account.
- Register with Cloud Identity (paid)
Prepare the organization's identity provider that IAP requires.
- Sign up for Google Cloud (free tier available)
Using the user account of the custom domain you created, sign up for Google Cloud and create an organization. Because IAP is enabled on projects under the organization, creating this organization is essential.
- Add a TXT record
For Cloud Identity, add the verification code Google issues to a DNS TXT record from your registrar's control panel to prove domain ownership.
- Download the sample code and install Terraform
Use GCP's official Cloud Run Explore as the foundation. It provides Terraform code that configures Cloud Run, the load balancer, and IAP all at once.
- Deploy the service
Set the required input values in tfvars and run terraform apply. This deploys Cloud Run, the external load balancer, and IAP together.
- Add an A record
To assign the created load balancer's IP address to the subdomain you want to publish, add an A record to your registrar's DNS. Propagation can take anywhere from a few hours to about 24 hours (in practice it became reachable in 1–2 hours).
- Access the subdomain
When you access the designated subdomain, you are redirected to Google's sign-in page. Sign in with an account permitted by IAP, and you reach the protected service.

- Clean up the service
After testing, delete the deployed resources with terraform destroy to avoid charges.
Code
Here is the configuration, adapted from GCP's sample Terraform. It manages the IAP client and brand, the external HTTPS load balancer, Cloud Run, and the security policy as a single set.
- main.tf
/**
* Copyright 2023 Google LLC
* Modifications Copyright 2024 Ryo M
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
locals {
gclb_create = var.custom_domain == null ? false : true
iap_sa_email = try(google_project_service_identity.iap_sa[0].email, "")
iap_brand = var.iap.enabled ? "projects/${module.project.number}/brands/${var.existing_iap_brand_id}" : null
}
module "project" {
source = "../../../modules/project"
billing_account = (var.project_create != null
? var.project_create.billing_account_id
: null
)
parent = (var.project_create != null
? var.project_create.parent
: null
)
name = var.project_id
services = [
"run.googleapis.com",
"compute.googleapis.com",
"iap.googleapis.com"
]
project_create = var.project_create != null
}
module "cloud_run" {
source = "../../../modules/cloud-run"
project_id = module.project.project_id
name = var.run_svc_name
region = var.region
containers = {
default = {
image = var.image
}
}
iam = {
"roles/run.invoker" = (local.gclb_create && var.iap.enabled
? ["serviceAccount:${local.iap_sa_email}"]
: ["allUsers"]
)
}
ingress_settings = var.ingress_settings
}
resource "google_compute_global_address" "default" {
count = local.gclb_create ? 1 : 0
project = module.project.project_id
name = "glb-ip"
}
module "glb" {
source = "../../../modules/net-lb-app-ext"
count = local.gclb_create ? 1 : 0
project_id = module.project.project_id
name = "glb"
address = google_compute_global_address.default[0].address
backend_service_configs = {
default = {
backends = [
{ backend = "neg-0" }
]
health_checks = []
port_name = "http"
security_policy = try(google_compute_security_policy.policy[0].name,
null)
iap_config = try({
oauth2_client_id = google_iap_client.iap_client[0].client_id,
oauth2_client_secret = google_iap_client.iap_client[0].secret
}, null)
}
}
health_check_configs = {}
neg_configs = {
neg-0 = {
cloudrun = {
region = var.region
target_service = {
name = var.run_svc_name
}
}
}
}
protocol = "HTTPS"
ssl_certificates = {
managed_configs = {
default = {
domains = [var.custom_domain]
}
}
}
}
resource "google_compute_security_policy" "policy" {
count = local.gclb_create && var.security_policy.enabled ? 1 : 0
name = "cloud-run-policy"
project = module.project.project_id
rule {
action = "deny(403)"
priority = 1000
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = var.security_policy.ip_blacklist
}
}
description = "Deny access to list of IPs"
}
rule {
action = "deny(403)"
priority = 900
match {
expr {
expression = "request.path.matches(\"${var.security_policy.path_blocked}\")"
}
}
description = "Deny access to specific URL paths"
}
rule {
action = "allow"
priority = "2147483647"
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
description = "Default rule"
}
}
resource "google_iap_client" "iap_client" {
count = var.iap.enabled ? 1 : 0
display_name = var.iap.oauth2_client_name
brand = local.iap_brand
lifecycle {
precondition {
condition = var.existing_iap_brand_id != ""
error_message = "existing_iap_brand_id must be set when iap.enabled is true."
}
}
}
resource "google_iap_brand" "project_brand" {
count = var.iap.enabled && var.create_iap_brand ? 1 : 0
support_email = var.iap.email
application_title = var.iap.application_title
project = module.project.project_id
}
resource "google_iap_web_iam_member" "iap_iam" {
count = local.gclb_create && var.iap.enabled ? 1 : 0
project = module.project.project_id
role = "roles/iap.httpsResourceAccessor"
member = "user:${var.iap.email}"
}
resource "google_project_service_identity" "iap_sa" {
provider = google-beta
count = local.gclb_create && var.iap.enabled ? 1 : 0
project = module.project.project_id
service = "iap.googleapis.com"
}
output "iap_brand" {
value = local.iap_brand
}
output "iap_client" {
value = {
enabled = var.iap.enabled
id = var.iap.enabled ? google_iap_client.iap_client[0].id : null
display_name = var.iap.enabled ? google_iap_client.iap_client[0].display_name : null
client_id = var.iap.enabled ? google_iap_client.iap_client[0].client_id : null
}
}
output "project_number" {
value = module.project.number
}- variables.tf
/**
* Copyright 2023 Google LLC
* Modifications Copyright 2024 Ryo M
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
variable "project_id" {
type = string
description = "Project ID"
}
variable "project_create" {
type = object({
billing_account_id = string
parent = string
})
description = "Project creation parameters"
default = null
}
variable "run_svc_name" {
type = string
description = "Cloud Run service name"
}
variable "region" {
type = string
description = "Region for Cloud Run service"
}
variable "image" {
type = string
description = "Container image for Cloud Run service"
}
variable "ingress_settings" {
type = string
description = "Ingress settings for Cloud Run service"
default = "all"
}
variable "custom_domain" {
type = string
description = "Custom domain for the load balancer"
default = null
}
variable "security_policy" {
type = object({
enabled = bool
ip_blacklist = list(string)
path_blocked = string
})
description = "Security policy settings"
default = {
enabled = false
ip_blacklist = []
path_blocked = ""
}
}
variable "iap" {
type = object({
enabled = bool
email = string
oauth2_client_name = string
application_title = string
})
description = "IAP settings"
}
variable "create_iap_brand" {
type = bool
default = false
description = "Whether to create a new IAP brand"
}
variable "existing_iap_brand_id" {
type = string
description = "The ID of the existing IAP brand (usually the project number)"
}- terraform.tfvars
project_id = "your_project_id"
run_svc_name = "my-cloud-run-service"
region = "europe-west1"
image = "us-docker.pkg.dev/cloudrun/container/hello"
ingress_settings = "internal-and-cloud-load-balancing"
custom_domain = "your_domain"
security_policy = {
enabled = true
ip_blacklist = ["79.149.0.0/16"]
path_blocked = "/admin/*"
}
iap = {
enabled = true
email = "your_email"
oauth2_client_name = "Cloud Run IAP"
application_title = "My Application"
}
create_iap_brand = false
existing_iap_brand_id = "your brand"
project_create = null- outputs.tf
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
# Custom domain for the Load Balancer. I'd prefer getting the value from the
# SSL certificate but it is not exported as output
output "custom_domain" {
description = "Custom domain for the Load Balancer."
value = local.gclb_create ? var.custom_domain : "none"
}
output "default_URL" {
description = "Cloud Run service default URL."
value = module.cloud_run.service.status[0].url
}
output "load_balancer_ip" {
description = "LB IP that forwards to Cloud Run service."
value = local.gclb_create ? module.glb[0].address : "none"
}
You can obtain the Brand ID via Method: projects.brands.list. Set existing_iap_brand_id to this value (usually the project-number-based brand ID).
Pitfalls and Key Points
- Attaching a custom domain alone is not enough. Because IAP presupposes resources under an organization, Workspace / Cloud Identity registration and organization creation are effectively mandatory requirements. Understanding this dependency before you start prevents rework.
- Enabling IAP requires a brand (consent screen) and an OAuth client. If the brand has not been created,
existing_iap_brand_idwill be empty and thegoogle_iap_clientprecondition will reject it. If you already have a brand, setcreate_iap_brand = falseand reuse its ID. - There is a lag before DNS A records propagate. If it is not reachable, suspect the TTL and propagation wait, and try again after a while.
- A test environment incurs charges from the load balancer, managed certificate, and so on. When you are done, clean up reliably with
terraform destroy.
Additional References
- Building an internal-only service with Cloud Run and Identity-Aware Proxy (Japanese)
- Implementing IAP protection for a Cloud Run service with Terraform (Japanese)
Closing
With IAP, you can configure a private deployment that delegates authentication and authorization to the infrastructure layer without modifying the app. Once you have a handle on the dependencies involving Workspace, Cloud Identity, DNS, and Terraform, you can run it in production as highly reproducible IaC. For consultation on cloud authentication, private deployments, or IaC, use the contact form.