Configure GCP Firebase Hosting Custom Domains

The gcp:firebase/hostingCustomDomain:HostingCustomDomain resource, part of the Pulumi Google Cloud provider, links custom domain names to Firebase Hosting sites, enabling Hosting to serve content on those domains with SSL certificates. This guide focuses on four capabilities: basic domain linking, SSL certificate type selection, domain redirects, and Cloud Run service integration.

Custom domains require existing Firebase Hosting sites and DNS control for the domain. Cloud Run integration requires additional Hosting version and release resources. The examples are intentionally small. Combine them with your own DNS configuration and backend services.

Most deployments start by connecting a domain name to an existing site, allowing Hosting to serve content on that domain.

import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";

const _default = new gcp.firebase.HostingCustomDomain("default", {
    project: "my-project-name",
    siteId: "site-id",
    customDomain: "custom.domain.com",
});
import pulumi
import pulumi_gcp as gcp

default = gcp.firebase.HostingCustomDomain("default",
    project="my-project-name",
    site_id="site-id",
    custom_domain="custom.domain.com")
package main

import (
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/firebase"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		_, err := firebase.NewHostingCustomDomain(ctx, "default", &firebase.HostingCustomDomainArgs{
			Project:      pulumi.String("my-project-name"),
			SiteId:       pulumi.String("site-id"),
			CustomDomain: pulumi.String("custom.domain.com"),
		})
		if err != nil {
			return err
		}
		return nil
	})
}
using System.Collections.Generic;
using System.Linq;
using Pulumi;
using Gcp = Pulumi.Gcp;

return await Deployment.RunAsync(() => 
{
    var @default = new Gcp.Firebase.HostingCustomDomain("default", new()
    {
        Project = "my-project-name",
        SiteId = "site-id",
        CustomDomain = "custom.domain.com",
    });

});
package generated_program;

import com.pulumi.Context;
import com.pulumi.Pulumi;
import com.pulumi.core.Output;
import com.pulumi.gcp.firebase.HostingCustomDomain;
import com.pulumi.gcp.firebase.HostingCustomDomainArgs;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;

public class App {
    public static void main(String[] args) {
        Pulumi.run(App::stack);
    }

    public static void stack(Context ctx) {
        var default_ = new HostingCustomDomain("default", HostingCustomDomainArgs.builder()
            .project("my-project-name")
            .siteId("site-id")
            .customDomain("custom.domain.com")
            .build());

    }
}
resources:
  default:
    type: gcp:firebase:HostingCustomDomain
    properties:
      project: my-project-name
      siteId: site-id
      customDomain: custom.domain.com

The customDomain property specifies the domain name you want to use. The siteId references an existing Firebase Hosting site. After creating this resource, you’ll need to configure DNS records (provided in the requiredDnsUpdates output) to verify ownership and route traffic to Firebase Hosting.

Configure certificate type and domain redirects

Some deployments need to control SSL certificate grouping or redirect one domain to another, such as redirecting www to the apex domain.

import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";

const _default = new gcp.firebase.HostingSite("default", {
    project: "my-project-name",
    siteId: "site-id-full",
});
const defaultHostingCustomDomain = new gcp.firebase.HostingCustomDomain("default", {
    project: "my-project-name",
    siteId: _default.siteId,
    customDomain: "source.domain.com",
    certPreference: "GROUPED",
    redirectTarget: "destination.domain.com",
    waitDnsVerification: false,
});
import pulumi
import pulumi_gcp as gcp

default = gcp.firebase.HostingSite("default",
    project="my-project-name",
    site_id="site-id-full")
default_hosting_custom_domain = gcp.firebase.HostingCustomDomain("default",
    project="my-project-name",
    site_id=default.site_id,
    custom_domain="source.domain.com",
    cert_preference="GROUPED",
    redirect_target="destination.domain.com",
    wait_dns_verification=False)
package main

import (
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/firebase"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		_default, err := firebase.NewHostingSite(ctx, "default", &firebase.HostingSiteArgs{
			Project: pulumi.String("my-project-name"),
			SiteId:  pulumi.String("site-id-full"),
		})
		if err != nil {
			return err
		}
		_, err = firebase.NewHostingCustomDomain(ctx, "default", &firebase.HostingCustomDomainArgs{
			Project:             pulumi.String("my-project-name"),
			SiteId:              _default.SiteId,
			CustomDomain:        pulumi.String("source.domain.com"),
			CertPreference:      pulumi.String("GROUPED"),
			RedirectTarget:      pulumi.String("destination.domain.com"),
			WaitDnsVerification: pulumi.Bool(false),
		})
		if err != nil {
			return err
		}
		return nil
	})
}
using System.Collections.Generic;
using System.Linq;
using Pulumi;
using Gcp = Pulumi.Gcp;

return await Deployment.RunAsync(() => 
{
    var @default = new Gcp.Firebase.HostingSite("default", new()
    {
        Project = "my-project-name",
        SiteId = "site-id-full",
    });

    var defaultHostingCustomDomain = new Gcp.Firebase.HostingCustomDomain("default", new()
    {
        Project = "my-project-name",
        SiteId = @default.SiteId,
        CustomDomain = "source.domain.com",
        CertPreference = "GROUPED",
        RedirectTarget = "destination.domain.com",
        WaitDnsVerification = false,
    });

});
package generated_program;

import com.pulumi.Context;
import com.pulumi.Pulumi;
import com.pulumi.core.Output;
import com.pulumi.gcp.firebase.HostingSite;
import com.pulumi.gcp.firebase.HostingSiteArgs;
import com.pulumi.gcp.firebase.HostingCustomDomain;
import com.pulumi.gcp.firebase.HostingCustomDomainArgs;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;

public class App {
    public static void main(String[] args) {
        Pulumi.run(App::stack);
    }

    public static void stack(Context ctx) {
        var default_ = new HostingSite("default", HostingSiteArgs.builder()
            .project("my-project-name")
            .siteId("site-id-full")
            .build());

        var defaultHostingCustomDomain = new HostingCustomDomain("defaultHostingCustomDomain", HostingCustomDomainArgs.builder()
            .project("my-project-name")
            .siteId(default_.siteId())
            .customDomain("source.domain.com")
            .certPreference("GROUPED")
            .redirectTarget("destination.domain.com")
            .waitDnsVerification(false)
            .build());

    }
}
resources:
  default:
    type: gcp:firebase:HostingSite
    properties:
      project: my-project-name
      siteId: site-id-full
  defaultHostingCustomDomain:
    type: gcp:firebase:HostingCustomDomain
    name: default
    properties:
      project: my-project-name
      siteId: ${default.siteId}
      customDomain: source.domain.com
      certPreference: GROUPED
      redirectTarget: destination.domain.com
      waitDnsVerification: false

The certPreference property controls how Firebase provisions SSL certificates. GROUPED shares certificates across multiple domains (Spark plan only option), while PROJECT_GROUPED and DEDICATED provide more isolation (Blaze plan). The redirectTarget property makes Hosting respond with HTTP 301 redirects, routing traffic to the specified destination domain. Setting waitDnsVerification to false allows Terraform to proceed without waiting for DNS propagation, storing any issues in state for later resolution.

Route custom domain traffic to Cloud Run services

Applications that combine Firebase Hosting with Cloud Run can serve dynamic content from containerized services while using Hosting’s CDN and custom domain features.

import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";

const _default = new gcp.firebase.HostingSite("default", {
    project: "my-project-name",
    siteId: "site-id",
});
const defaultService = new gcp.cloudrunv2.Service("default", {
    project: "my-project-name",
    name: "cloud-run-service-via-hosting",
    location: "us-central1",
    ingress: "INGRESS_TRAFFIC_ALL",
    template: {
        containers: [{
            image: "us-docker.pkg.dev/cloudrun/container/hello",
        }],
    },
    deletionProtection: true,
});
const defaultHostingVersion = new gcp.firebase.HostingVersion("default", {
    siteId: _default.siteId,
    config: {
        rewrites: [{
            glob: "/hello/**",
            run: {
                serviceId: defaultService.name,
                region: defaultService.location,
            },
        }],
    },
});
const defaultHostingRelease = new gcp.firebase.HostingRelease("default", {
    siteId: _default.siteId,
    versionName: defaultHostingVersion.name,
    message: "Cloud Run Integration",
});
const defaultHostingCustomDomain = new gcp.firebase.HostingCustomDomain("default", {
    project: "my-project-name",
    siteId: _default.siteId,
    customDomain: "run.custom.domain.com",
    waitDnsVerification: false,
});
import pulumi
import pulumi_gcp as gcp

default = gcp.firebase.HostingSite("default",
    project="my-project-name",
    site_id="site-id")
default_service = gcp.cloudrunv2.Service("default",
    project="my-project-name",
    name="cloud-run-service-via-hosting",
    location="us-central1",
    ingress="INGRESS_TRAFFIC_ALL",
    template={
        "containers": [{
            "image": "us-docker.pkg.dev/cloudrun/container/hello",
        }],
    },
    deletion_protection=True)
default_hosting_version = gcp.firebase.HostingVersion("default",
    site_id=default.site_id,
    config={
        "rewrites": [{
            "glob": "/hello/**",
            "run": {
                "service_id": default_service.name,
                "region": default_service.location,
            },
        }],
    })
default_hosting_release = gcp.firebase.HostingRelease("default",
    site_id=default.site_id,
    version_name=default_hosting_version.name,
    message="Cloud Run Integration")
default_hosting_custom_domain = gcp.firebase.HostingCustomDomain("default",
    project="my-project-name",
    site_id=default.site_id,
    custom_domain="run.custom.domain.com",
    wait_dns_verification=False)
package main

import (
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudrunv2"
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/firebase"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		_default, err := firebase.NewHostingSite(ctx, "default", &firebase.HostingSiteArgs{
			Project: pulumi.String("my-project-name"),
			SiteId:  pulumi.String("site-id"),
		})
		if err != nil {
			return err
		}
		defaultService, err := cloudrunv2.NewService(ctx, "default", &cloudrunv2.ServiceArgs{
			Project:  pulumi.String("my-project-name"),
			Name:     pulumi.String("cloud-run-service-via-hosting"),
			Location: pulumi.String("us-central1"),
			Ingress:  pulumi.String("INGRESS_TRAFFIC_ALL"),
			Template: &cloudrunv2.ServiceTemplateArgs{
				Containers: cloudrunv2.ServiceTemplateContainerArray{
					&cloudrunv2.ServiceTemplateContainerArgs{
						Image: pulumi.String("us-docker.pkg.dev/cloudrun/container/hello"),
					},
				},
			},
			DeletionProtection: pulumi.Bool(true),
		})
		if err != nil {
			return err
		}
		defaultHostingVersion, err := firebase.NewHostingVersion(ctx, "default", &firebase.HostingVersionArgs{
			SiteId: _default.SiteId,
			Config: &firebase.HostingVersionConfigArgs{
				Rewrites: firebase.HostingVersionConfigRewriteArray{
					&firebase.HostingVersionConfigRewriteArgs{
						Glob: pulumi.String("/hello/**"),
						Run: &firebase.HostingVersionConfigRewriteRunArgs{
							ServiceId: defaultService.Name,
							Region:    defaultService.Location,
						},
					},
				},
			},
		})
		if err != nil {
			return err
		}
		_, err = firebase.NewHostingRelease(ctx, "default", &firebase.HostingReleaseArgs{
			SiteId:      _default.SiteId,
			VersionName: defaultHostingVersion.Name,
			Message:     pulumi.String("Cloud Run Integration"),
		})
		if err != nil {
			return err
		}
		_, err = firebase.NewHostingCustomDomain(ctx, "default", &firebase.HostingCustomDomainArgs{
			Project:             pulumi.String("my-project-name"),
			SiteId:              _default.SiteId,
			CustomDomain:        pulumi.String("run.custom.domain.com"),
			WaitDnsVerification: pulumi.Bool(false),
		})
		if err != nil {
			return err
		}
		return nil
	})
}
using System.Collections.Generic;
using System.Linq;
using Pulumi;
using Gcp = Pulumi.Gcp;

return await Deployment.RunAsync(() => 
{
    var @default = new Gcp.Firebase.HostingSite("default", new()
    {
        Project = "my-project-name",
        SiteId = "site-id",
    });

    var defaultService = new Gcp.CloudRunV2.Service("default", new()
    {
        Project = "my-project-name",
        Name = "cloud-run-service-via-hosting",
        Location = "us-central1",
        Ingress = "INGRESS_TRAFFIC_ALL",
        Template = new Gcp.CloudRunV2.Inputs.ServiceTemplateArgs
        {
            Containers = new[]
            {
                new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerArgs
                {
                    Image = "us-docker.pkg.dev/cloudrun/container/hello",
                },
            },
        },
        DeletionProtection = true,
    });

    var defaultHostingVersion = new Gcp.Firebase.HostingVersion("default", new()
    {
        SiteId = @default.SiteId,
        Config = new Gcp.Firebase.Inputs.HostingVersionConfigArgs
        {
            Rewrites = new[]
            {
                new Gcp.Firebase.Inputs.HostingVersionConfigRewriteArgs
                {
                    Glob = "/hello/**",
                    Run = new Gcp.Firebase.Inputs.HostingVersionConfigRewriteRunArgs
                    {
                        ServiceId = defaultService.Name,
                        Region = defaultService.Location,
                    },
                },
            },
        },
    });

    var defaultHostingRelease = new Gcp.Firebase.HostingRelease("default", new()
    {
        SiteId = @default.SiteId,
        VersionName = defaultHostingVersion.Name,
        Message = "Cloud Run Integration",
    });

    var defaultHostingCustomDomain = new Gcp.Firebase.HostingCustomDomain("default", new()
    {
        Project = "my-project-name",
        SiteId = @default.SiteId,
        CustomDomain = "run.custom.domain.com",
        WaitDnsVerification = false,
    });

});
package generated_program;

import com.pulumi.Context;
import com.pulumi.Pulumi;
import com.pulumi.core.Output;
import com.pulumi.gcp.firebase.HostingSite;
import com.pulumi.gcp.firebase.HostingSiteArgs;
import com.pulumi.gcp.cloudrunv2.Service;
import com.pulumi.gcp.cloudrunv2.ServiceArgs;
import com.pulumi.gcp.cloudrunv2.inputs.ServiceTemplateArgs;
import com.pulumi.gcp.firebase.HostingVersion;
import com.pulumi.gcp.firebase.HostingVersionArgs;
import com.pulumi.gcp.firebase.inputs.HostingVersionConfigArgs;
import com.pulumi.gcp.firebase.HostingRelease;
import com.pulumi.gcp.firebase.HostingReleaseArgs;
import com.pulumi.gcp.firebase.HostingCustomDomain;
import com.pulumi.gcp.firebase.HostingCustomDomainArgs;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;

public class App {
    public static void main(String[] args) {
        Pulumi.run(App::stack);
    }

    public static void stack(Context ctx) {
        var default_ = new HostingSite("default", HostingSiteArgs.builder()
            .project("my-project-name")
            .siteId("site-id")
            .build());

        var defaultService = new Service("defaultService", ServiceArgs.builder()
            .project("my-project-name")
            .name("cloud-run-service-via-hosting")
            .location("us-central1")
            .ingress("INGRESS_TRAFFIC_ALL")
            .template(ServiceTemplateArgs.builder()
                .containers(ServiceTemplateContainerArgs.builder()
                    .image("us-docker.pkg.dev/cloudrun/container/hello")
                    .build())
                .build())
            .deletionProtection(true)
            .build());

        var defaultHostingVersion = new HostingVersion("defaultHostingVersion", HostingVersionArgs.builder()
            .siteId(default_.siteId())
            .config(HostingVersionConfigArgs.builder()
                .rewrites(HostingVersionConfigRewriteArgs.builder()
                    .glob("/hello/**")
                    .run(HostingVersionConfigRewriteRunArgs.builder()
                        .serviceId(defaultService.name())
                        .region(defaultService.location())
                        .build())
                    .build())
                .build())
            .build());

        var defaultHostingRelease = new HostingRelease("defaultHostingRelease", HostingReleaseArgs.builder()
            .siteId(default_.siteId())
            .versionName(defaultHostingVersion.name())
            .message("Cloud Run Integration")
            .build());

        var defaultHostingCustomDomain = new HostingCustomDomain("defaultHostingCustomDomain", HostingCustomDomainArgs.builder()
            .project("my-project-name")
            .siteId(default_.siteId())
            .customDomain("run.custom.domain.com")
            .waitDnsVerification(false)
            .build());

    }
}
resources:
  default:
    type: gcp:firebase:HostingSite
    properties:
      project: my-project-name
      siteId: site-id
  defaultService:
    type: gcp:cloudrunv2:Service
    name: default
    properties:
      project: my-project-name
      name: cloud-run-service-via-hosting
      location: us-central1
      ingress: INGRESS_TRAFFIC_ALL
      template:
        containers:
          - image: us-docker.pkg.dev/cloudrun/container/hello
      deletionProtection: true
  defaultHostingVersion:
    type: gcp:firebase:HostingVersion
    name: default
    properties:
      siteId: ${default.siteId}
      config:
        rewrites:
          - glob: /hello/**
            run:
              serviceId: ${defaultService.name}
              region: ${defaultService.location}
  defaultHostingRelease:
    type: gcp:firebase:HostingRelease
    name: default
    properties:
      siteId: ${default.siteId}
      versionName: ${defaultHostingVersion.name}
      message: Cloud Run Integration
  defaultHostingCustomDomain:
    type: gcp:firebase:HostingCustomDomain
    name: default
    properties:
      project: my-project-name
      siteId: ${default.siteId}
      customDomain: run.custom.domain.com
      waitDnsVerification: false

This configuration creates a complete integration chain: a Cloud Run service, a Hosting version with rewrite rules, a release to activate that version, and finally the custom domain. The rewrites block in HostingVersion routes requests matching /hello/** to the Cloud Run service. The custom domain then makes this entire setup accessible via your own domain name, with Firebase Hosting handling SSL and CDN.

Beyond these examples

These snippets focus on specific custom domain features: custom domain linking and DNS verification, SSL certificate management and domain redirects, and Cloud Run integration with rewrite rules. They’re intentionally minimal rather than full hosting deployments.

The examples may reference pre-existing infrastructure such as Firebase Hosting sites (or created inline), DNS control for custom domains, and Cloud Run services (for integration example). They focus on configuring the custom domain rather than provisioning everything around it.

To keep things focused, common custom domain patterns are omitted, including:

  • DNS record configuration details (requiredDnsUpdates output)
  • Ownership verification with TXT records
  • Certificate state monitoring (certs, hostState, ownershipState outputs)
  • Error handling for DNS issues (issues output)

These omissions are intentional: the goal is to illustrate how each custom domain feature is wired, not provide drop-in hosting modules. See the Firebase Hosting CustomDomain resource reference for all available configuration options.

Let's configure GCP Firebase Hosting Custom Domains

Get started with Pulumi Cloud, then follow our quick setup guide to deploy this infrastructure.

Try Pulumi Cloud for FREE

Frequently Asked Questions

Setup & Configuration
Why do I need to use the beta provider for this resource?
Firebase Hosting Custom Domains are in beta and require the terraform-provider-google-beta provider.
What properties can't be changed after creation?
The customDomain, project, siteId, and waitDnsVerification properties are immutable and require resource replacement if changed.
What does waitDnsVerification control?
If waitDnsVerification is true, Terraform waits for DNS records to be fully resolved before completing. If false, Terraform proceeds immediately and stores any DNS issues in state.
SSL Certificates
What's the difference between the certificate types?

Firebase Hosting offers three certificate types via certPreference:

  • GROUPED: Shared certificate (only option for Spark plan)
  • PROJECT_GROUPED: Project-level shared certificate (Blaze plan only)
  • DEDICATED: Dedicated certificate for your domain (Blaze plan only)
DNS & Ownership Verification
What do the different ownership states mean?

Ownership is determined by TXT records in your DNS:

  • OWNERSHIP_MISSING: No ownership records found
  • OWNERSHIP_UNREACHABLE: DNS queries failed
  • OWNERSHIP_MISMATCH: Owned by another Firebase project
  • OWNERSHIP_CONFLICT: Conflicting TXT records from multiple projects
  • OWNERSHIP_PENDING: DNS configured correctly, ownership transfer within 24 hours
  • OWNERSHIP_ACTIVE: Your project has verified ownership
How do I find out what DNS records to add?
Check the requiredDnsUpdates output property after creating the custom domain. It provides the specific DNS records needed for your domain to work with Hosting.
What happens if my domain isn't verified within 30 days?
If your custom domain remains in a non-OWNERSHIP_ACTIVE state for more than 30 days without updates, Hosting’s ownership systems automatically delete it.
Domain States & Troubleshooting
What do the different host states mean?

Host state indicates whether your domain’s IP addresses serve Hosting content:

  • HOST_UNHOSTED: No IP addresses associated with domain
  • HOST_UNREACHABLE: DNS queries failed (check issues field)
  • HOST_MISMATCH: IP addresses don’t resolve to Hosting
  • HOST_CONFLICT: IP addresses resolve to both Hosting and other services
  • HOST_ACTIVE: All requests served by Hosting
What does it mean when reconciling is true?
When reconciling is true, Hosting’s systems are actively working to match your custom domain’s actual state with your desired configuration. This commonly occurs during initial provisioning or when creating new SSL certificates.
Redirects & Advanced Features
How do I redirect one domain to another?
Set the redirectTarget property to the destination domain. Hosting will respond with HTTP 301 redirects, routing traffic to the specified target instead of serving content directly.
Can I use custom domains with Cloud Run services?
Yes. Create a HostingVersion with rewrites pointing to your Cloud Run service (using serviceId and region), then associate your custom domain with the site.
Lifecycle & Deletion
What happens when I delete a custom domain?
Deleted custom domains persist for approximately 30 days before Hosting removes them completely. During this period, the domain shows a deleteTime value.

Using a different cloud?

Explore networking guides for other cloud providers: