How to set up a custom domain and HTTPS redirects on Cloud Run

I'll show you how to set up a custom domain for a Cloud Run service using Cloud Load Balancing instead of the default *.run.app URL. Other options include Firebase Hosting or a third-party CDN (Cloud Run's built-in domain mapping is in preview and not recommended for production).

An external Application Load Balancer doesn't have a free tier (see Cloud Load Balancing pricing), but it lets you add Cloud CDN caching, Google Cloud Armor, URL routing across multiple services, and Google-managed SSL certificates.

Here's the architecture you're going to build. First, enable the required APIs, reserve a static IP, and update your DNS records. Then, start the provisioning process of a managed SSL certificate. While that's running, deploy a Cloud Run service, and set up the load balancer in front of it, including injected HSTS headers and the HTTP-to-HTTPS redirect.

Architecture diagram of a Google Cloud External Application Load Balancer routing port 80 HTTP requests through a 301 redirect map and port 443 HTTPS requests through a managed SSL certificate, HSTS backend service, and serverless NEG to a Cloud Run service.

Prerequisites

Before you start, make sure you have:

  • A domain name you control.
  • gcloud CLI installed and authenticated.
  • A Google Cloud project with an attached billing account.

1. Enable the required APIs

Enable the Compute Engine (for the load balancer) and Cloud Run APIs in your Google Cloud project.

gcloud services enable compute.googleapis.com run.googleapis.com

2. Reserve a static IP and update DNS

The next step is to reserve a global static IP address and point your domain to it.

Reserve a static IP address

gcloud compute addresses create lb-ip --global

To see your IP address, run:

gcloud compute addresses describe lb-ip --global --format='get(address)'

Update your DNS

Go to your DNS provider and point your domain's A record to the static IP address you just reserved. By pointing your domain to the load balancer's IP, you give Google the proof it needs to issue a certificate for that domain. It's how you prove you own the domain.

Note that Google's validation service needs to reach your load balancer's IP address directly. Intermediate layers such as a third-party CDN can cause the certificate provisioning to fail.

If load balancer authorization doesn't work for you, there's an alternative way to prove ownership that requires creating a CNAME record for validation but offers more flexibility for complex configurations.

3. Provision a managed SSL certificate

To secure your traffic with HTTPS, you need an SSL certificate. Google Cloud handles provisioning and renewal for you. Replace [DOMAIN_NAME] with your own domain in the command below.

gcloud compute ssl-certificates create lb-cert \
    --domains=[DOMAIN_NAME] \
    --global

The certificate stays in a PROVISIONING state until the load balancer is live (you'll create it soon) and DNS is propagated. It typically takes up to 60 minutes to become ACTIVE once the configuration is complete.

4. Deploy an example Cloud Run service

Now, deploy a web service. I'm using the official NGINX image as an example here. It's a web server, which works great to prove this configuration works. You can deploy your own app later.

gcloud run deploy run-service \
    --image=nginx \
    --region=europe-west1 \
    --allow-unauthenticated \
    --ingress=internal-and-cloud-load-balancing \
    --port=80

The --allow-unauthenticated flag makes the service accessible without IAM authentication. The --port=80 flag is required because the NGINX image listens on port 80, whereas Cloud Run defaults to port 8080.

The --ingress=internal-and-cloud-load-balancing flag blocks direct public access to the default https://run-service-<hash>-ew.a.run.app URL. By default, Cloud Run sets ingress to all. If clients reach the *.run.app endpoint directly, they skip the load balancer (along with its HSTS header injection, HTTP-to-HTTPS redirect, and any Cloud Armor security policies). With internal-and-cloud-load-balancing, Google's edge proxy rejects direct requests to *.run.app so external traffic can only enter through the load balancer.

5. Configure a serverless backend

A serverless backend is a global resource and connects Cloud Load Balancing to your Cloud Run service.

Set up the backend service

The backend service is a central configuration point where you enable features like Cloud CDN caching, Cloud Armor, and custom headers.

gcloud compute backend-services create backend-service \
    --load-balancing-scheme=EXTERNAL_MANAGED \
    --custom-response-header='Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' \
    --global

The --load-balancing-scheme=EXTERNAL_MANAGED flag selects Google Cloud's global external Application Load Balancer (rather than the legacy EXTERNAL classic load balancer). The --custom-response-header flag instructs the load balancer to automatically inject the Strict-Transport-Security HTTP header into all responses sent back to the client. This security header tells browsers to never use HTTP to make requests, preventing downgrade attacks.

Notice that the command omits the --protocol flag. When configuring an HTTPS load balancer, it's tempting to pass --protocol=HTTPS. However, backend services attached to Serverless NEGs don't support port names. Passing --protocol=HTTPS injects a default port name (https), which causes gcloud to fail with an API rejection:

Invalid value for field 'resource.portName': 'https'.
Port name is not supported for a backend service with Serverless network endpoint groups.

Omit --protocol (or leave it at the default HTTP scheme). Google Cloud's internal network automatically manages transport security and encryption between the edge proxy and Cloud Run.

Create a serverless network endpoint group

Now that you have created a backend service, you can connect the Cloud Run service to it. Cloud Load Balancing requires a regional configuration object for it, a serverless network endpoint group (NEG). This object represents the Cloud Run service within Cloud Load Balancing.

gcloud compute network-endpoint-groups create run-neg \
    --region=europe-west1 \
    --network-endpoint-type=serverless \
    --cloud-run-service=run-service

Add the serverless NEG to the backend service

Plug in the Cloud Run service to the backend using the NEG.

gcloud compute backend-services add-backend backend-service \
    --global \
    --network-endpoint-group=run-neg \
    --network-endpoint-group-region=europe-west1

Create the URL map

The URL map is the router for your load balancer. It inspects the hostname and path of incoming requests and directs them to the appropriate backend service. This configuration specifies to forward all requests to the backend service.

gcloud compute url-maps create url-map \
    --default-service=backend-service

This routing capability is a major advantage of using Cloud Load Balancing over standard Cloud Run domain mappings. While domain mappings only direct traffic to a single service, an Application Load Balancer URL map supports path-based routing. You can route different URL paths to different services (for example, sending /api/* to a backend service and all other traffic to a frontend web application).

6. Set up the HTTPS frontend

The frontend configuration defines how clients reach your service over HTTPS. It consists of a target proxy and a forwarding rule.

Create the target HTTPS proxy

The target HTTPS proxy terminates the SSL connection using the certificate you created in 3. Provision a managed SSL certificate.

gcloud compute target-https-proxies create https-proxy \
    --ssl-certificates=lb-cert \
    --url-map=url-map

Create the HTTPS forwarding rule

The forwarding rule connects your static IP address and port 443 (HTTPS) to the target proxy.

gcloud compute forwarding-rules create https-rule \
    --load-balancing-scheme=EXTERNAL_MANAGED \
    --address=lb-ip \
    --target-https-proxy=https-proxy \
    --global \
    --ports=443

7. Set up the HTTP-to-HTTPS redirect

The HSTS header tells browsers to always use HTTPS. However, you still need a redirector to catch and upgrade a client's very first unencrypted request, in case they request the HTTP endpoint.

Create the redirect URL map

Import a URL map that returns a 301 Moved Permanently redirect. Passing --source /dev/stdin <<EOF feeds the YAML redirect specification directly from standard input without needing a temporary file, and MOVED_PERMANENTLY_DEFAULT sets the HTTP status code to 301:

gcloud compute url-maps import redirect-map \
    --global \
    --source /dev/stdin <<EOF
name: redirect-map
defaultUrlRedirect:
  redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
  httpsRedirect: True
EOF

Create the target HTTP proxy

gcloud compute target-http-proxies create http-proxy \
    --url-map=redirect-map

Create the HTTP forwarding rule

This rule uses the same static IP as the HTTPS rule, but listens on port 80.

gcloud compute forwarding-rules create http-rule \
    --load-balancing-scheme=EXTERNAL_MANAGED \
    --address=lb-ip \
    --target-http-proxy=http-proxy \
    --global \
    --ports=80

Verification

Once you have completed the configuration, verify that the certificate is active and the redirect and HSTS header work as expected.

Check certificate status

Google proves your domain ownership by attempting to reach your load balancer from multiple locations across the internet. Monitor the overall certificate status and per-domain validation status with gcloud:

gcloud compute ssl-certificates describe lb-cert \
    --global \
    --format="get(managed.status,managed.domainStatus)"

When provisioning completes, both statuses report ACTIVE:

ACTIVE	example.com=ACTIVE

If you see FAILED_NOT_VISIBLE, it usually means DNS has not yet propagated to all of Google's validation vantage points, or your load balancer isn't yet reachable on port 443.

Test HTTPS and redirects

Once the certificate is ACTIVE, test the HTTP redirect with curl -I (which fetches response headers only):

curl -I http://[DOMAIN_NAME]
HTTP/1.1 301 Moved Permanently
Location: https://[DOMAIN_NAME]:443/

Next, verify that the HTTPS endpoint returns 200 OK along with the strict-transport-security header:

curl -I https://[DOMAIN_NAME]
HTTP/2 200
server: nginx/1.27.4
strict-transport-security: max-age=31536000; includeSubDomains; preload

Let me know if you run into any questions setting this up!

Signature