IaC GCP

Terraform Module: GCP Cloud Domains — Register and Govern Domains as Code

Quick take — Build a production-ready Terraform module for GCP Cloud Domains using google_clouddomains_registration — handle contact privacy, custom DNS or glue records, transfer locks, auto-renewal, and the mandatory yearly_price guard from one var-driven interface. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "cloud_domains" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-domains?ref=v1.0.0"

  project_id         = "..."  # GCP project ID that owns and is billed for the registra…
  domain_name        = "..."  # Domain to register, e.g. `kloudvin.com` (lowercase FQDN…
  yearly_price_units = "..."  # Whole-currency units of the first-year price you attest…
  registrant_contact = {}     # Registrant (owner) WHOIS contact; reused for admin/tech…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Cloud Domains is Google Cloud’s domain registrar — it lets you register and renew internet domains (kloudvin.com, kloudvin.dev, …) as first-class GCP resources, billed to your project and visible in your IAM/audit plane instead of a third-party registrar console. Under the hood the API talks to Google’s registrar back end (the same one behind the former Google Domains), but the unit you manage is a google_clouddomains_registration: a single registered domain plus its contact details, DNS configuration, privacy setting, transfer lock, and renewal policy.

Registering a domain by hand is deceptively risky. The google_clouddomains_registration resource has sharp edges that trip people every release: it lives only in location = "global", it bills real money on apply (a registration is a year-long purchase, not a free control-plane object), and it requires a yearly_price block whose units/currency_code must exactly match the live price Cloud Domains quotes for that TLD — a mismatch fails the apply. Wrapping it in a module gives you one reviewed interface that:

The result: every domain your org owns is registered the same way, privacy and transfer-lock are never forgotten, the price guard is explicit, and “who owns this domain and when does it expire” is answerable from state.

When to use it

Reach for this module when:

Skip it (or treat it as a careful, gated apply) if you only need to transfer an existing domain in or run a one-off search for availability — those are different operations (google_clouddomains_registration registers new domains; transfers and the contact-privacy contact_notices/domain_notices acknowledgements have their own caveats). And never wire this into an unattended pipeline without spend controls: each apply that creates a registration charges your billing account for a full year.

Module structure

terraform-module-gcp-cloud-domains/
├── versions.tf       # provider + Terraform version pins
├── main.tf           # google_clouddomains_registration (contacts, DNS, mgmt)
├── variables.tf      # var-driven inputs with validations
└── outputs.tf        # id/name + state, expiry, name servers, issues

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Cloud Domains registrations exist ONLY in the global location.
  location = "global"

  # Build the three WHOIS contacts from a single registrant profile,
  # overriding admin/technical only when explicitly supplied.
  admin_contact     = coalesce(var.admin_contact, var.registrant_contact)
  technical_contact = coalesce(var.technical_contact, var.registrant_contact)
}

resource "google_clouddomains_registration" "this" {
  project     = var.project_id
  location    = local.location
  domain_name = var.domain_name

  labels = var.labels

  # Acknowledgements required by the registrar. HSTS_PRELOADED is mandatory
  # for TLDs that enforce HTTPS (e.g. .dev, .app); add it via var.domain_notices.
  domain_notices  = var.domain_notices
  contact_notices = var.contact_notices

  # The price you ATTEST to pay for the first year. Must match the live
  # Cloud Domains quote for this TLD exactly, or the apply fails.
  yearly_price {
    units         = var.yearly_price_units
    currency_code = var.yearly_price_currency
  }

  # Transfer lock + auto-renewal policy.
  management_settings {
    preferred_renewal_method = var.preferred_renewal_method
    transfer_lock_state      = var.transfer_lock_state
  }

  # DNS: either custom name servers (delegate to an existing zone) ...
  dns_settings {
    dynamic "custom_dns" {
      for_each = length(var.custom_name_servers) > 0 ? [1] : []
      content {
        name_servers = var.custom_name_servers

        # Optional DNSSEC DS records to publish at the registry.
        dynamic "ds_records" {
          for_each = var.ds_records
          content {
            key_tag     = ds_records.value.key_tag
            algorithm   = ds_records.value.algorithm
            digest_type = ds_records.value.digest_type
            digest      = ds_records.value.digest
          }
        }
      }
    }

    # ... or glue records for vanity name servers under this domain.
    dynamic "glue_records" {
      for_each = var.glue_records
      content {
        host_name    = glue_records.value.host_name
        ipv4_addresses = glue_records.value.ipv4_addresses
        ipv6_addresses = glue_records.value.ipv6_addresses
      }
    }
  }

  contact_settings {
    privacy = var.contact_privacy

    registrant_contact {
      email        = var.registrant_contact.email
      phone_number = var.registrant_contact.phone_number
      fax_number   = var.registrant_contact.fax_number

      postal_address {
        region_code         = var.registrant_contact.region_code
        postal_code         = var.registrant_contact.postal_code
        administrative_area = var.registrant_contact.administrative_area
        locality            = var.registrant_contact.locality
        organization        = var.registrant_contact.organization
        recipients          = [var.registrant_contact.recipient]
        address_lines       = var.registrant_contact.address_lines
      }
    }

    admin_contact {
      email        = local.admin_contact.email
      phone_number = local.admin_contact.phone_number
      fax_number   = local.admin_contact.fax_number

      postal_address {
        region_code         = local.admin_contact.region_code
        postal_code         = local.admin_contact.postal_code
        administrative_area = local.admin_contact.administrative_area
        locality            = local.admin_contact.locality
        organization        = local.admin_contact.organization
        recipients          = [local.admin_contact.recipient]
        address_lines       = local.admin_contact.address_lines
      }
    }

    technical_contact {
      email        = local.technical_contact.email
      phone_number = local.technical_contact.phone_number
      fax_number   = local.technical_contact.fax_number

      postal_address {
        region_code         = local.technical_contact.region_code
        postal_code         = local.technical_contact.postal_code
        administrative_area = local.technical_contact.administrative_area
        locality            = local.technical_contact.locality
        organization        = local.technical_contact.organization
        recipients          = [local.technical_contact.recipient]
        address_lines       = local.technical_contact.address_lines
      }
    }
  }

  # Registering a domain takes time and bills for a year; give it room
  # and protect it from accidental destroy below.
  timeouts {
    create = "30m"
    update = "30m"
    delete = "30m"
  }

  lifecycle {
    # The registrar will not let you re-register the same name cheaply;
    # require an explicit, deliberate change to replace it.
    prevent_destroy = false # set true for crown-jewel domains
  }
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns and is billed for the domain registration."
  type        = string
}

variable "domain_name" {
  description = "The domain to register, e.g. \"kloudvin.com\". Must be a supported TLD."
  type        = string

  validation {
    condition     = can(regex("^([a-z0-9-]+\\.)+[a-z]{2,}$", var.domain_name))
    error_message = "domain_name must be a valid lowercase FQDN such as \"kloudvin.com\" (no trailing dot, no protocol)."
  }
}

variable "yearly_price_units" {
  description = "Whole-currency units of the first-year price you attest to pay (e.g. 12 for $12.00). Must match the live Cloud Domains quote for this TLD."
  type        = string

  validation {
    condition     = can(regex("^[0-9]+$", var.yearly_price_units))
    error_message = "yearly_price_units must be a whole number expressed as a string, e.g. \"12\"."
  }
}

variable "yearly_price_currency" {
  description = "ISO 4217 currency code for yearly_price, e.g. USD. Must match the currency Cloud Domains quotes for your billing account."
  type        = string
  default     = "USD"

  validation {
    condition     = can(regex("^[A-Z]{3}$", var.yearly_price_currency))
    error_message = "yearly_price_currency must be a 3-letter uppercase ISO 4217 code, e.g. \"USD\"."
  }
}

variable "contact_privacy" {
  description = "WHOIS privacy mode for all contacts."
  type        = string
  default     = "PRIVATE_CONTACT_DATA"

  validation {
    condition = contains(
      ["PRIVATE_CONTACT_DATA", "REDACTED_CONTACT_DATA", "PUBLIC_CONTACT_DATA"],
      var.contact_privacy
    )
    error_message = "contact_privacy must be PRIVATE_CONTACT_DATA, REDACTED_CONTACT_DATA, or PUBLIC_CONTACT_DATA."
  }
}

variable "transfer_lock_state" {
  description = "Transfer lock for the domain. LOCKED prevents transfer away to another registrar."
  type        = string
  default     = "LOCKED"

  validation {
    condition     = contains(["LOCKED", "UNLOCKED"], var.transfer_lock_state)
    error_message = "transfer_lock_state must be LOCKED or UNLOCKED."
  }
}

variable "preferred_renewal_method" {
  description = "Renewal policy. AUTOMATIC_RENEWAL renews before expiry; RENEWAL_DISABLED lets the domain lapse."
  type        = string
  default     = "AUTOMATIC_RENEWAL"

  validation {
    condition     = contains(["AUTOMATIC_RENEWAL", "RENEWAL_DISABLED"], var.preferred_renewal_method)
    error_message = "preferred_renewal_method must be AUTOMATIC_RENEWAL or RENEWAL_DISABLED."
  }
}

variable "custom_name_servers" {
  description = "List of custom name servers to delegate the domain to (e.g. a Cloud DNS zone's name_servers). Mutually exclusive with glue_records."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.custom_name_servers) == 0 || length(var.custom_name_servers) >= 2
    error_message = "Provide at least two custom_name_servers, or none to use glue_records / Google managed DNS."
  }
}

variable "ds_records" {
  description = "Optional DNSSEC DS records to publish at the registry when using custom_name_servers."
  type = list(object({
    key_tag     = number
    algorithm   = string
    digest_type = string
    digest      = string
  }))
  default = []
}

variable "glue_records" {
  description = "Vanity name-server glue records hosted under this domain. Mutually exclusive with custom_name_servers."
  type = list(object({
    host_name      = string
    ipv4_addresses = optional(list(string), [])
    ipv6_addresses = optional(list(string), [])
  }))
  default = []
}

variable "registrant_contact" {
  description = "The registrant (owner) WHOIS contact. Also used for admin/technical unless those are overridden."
  type = object({
    email               = string
    phone_number        = string # E.164, e.g. "+1.5551234567"
    fax_number          = optional(string)
    region_code         = string # ISO 3166-1 alpha-2, e.g. "US"
    postal_code         = optional(string)
    administrative_area = optional(string)
    locality            = optional(string)
    organization        = optional(string)
    recipient           = string
    address_lines       = list(string)
  })

  validation {
    condition     = can(regex("^[^@]+@[^@]+\\.[^@]+$", var.registrant_contact.email))
    error_message = "registrant_contact.email must be a valid email address."
  }

  validation {
    condition     = can(regex("^\\+[0-9]{1,3}\\.[0-9]{4,}$", var.registrant_contact.phone_number))
    error_message = "registrant_contact.phone_number must be E.164 with a country prefix and dot, e.g. \"+1.5551234567\"."
  }
}

variable "admin_contact" {
  description = "Optional override for the administrative WHOIS contact. Defaults to registrant_contact."
  type = object({
    email               = string
    phone_number        = string
    fax_number          = optional(string)
    region_code         = string
    postal_code         = optional(string)
    administrative_area = optional(string)
    locality            = optional(string)
    organization        = optional(string)
    recipient           = string
    address_lines       = list(string)
  })
  default = null
}

variable "technical_contact" {
  description = "Optional override for the technical WHOIS contact. Defaults to registrant_contact."
  type = object({
    email               = string
    phone_number        = string
    fax_number          = optional(string)
    region_code         = string
    postal_code         = optional(string)
    administrative_area = optional(string)
    locality            = optional(string)
    organization        = optional(string)
    recipient           = string
    address_lines       = list(string)
  })
  default = null
}

variable "domain_notices" {
  description = "Registrar domain notices to acknowledge. Use [\"HSTS_PRELOADED\"] for HTTPS-enforced TLDs like .dev/.app."
  type        = list(string)
  default     = []
}

variable "contact_notices" {
  description = "Contact notices to acknowledge, e.g. [\"PUBLIC_CONTACT_DATA_ACKNOWLEDGEMENT\"] when privacy is PUBLIC_CONTACT_DATA."
  type        = list(string)
  default     = []
}

variable "labels" {
  description = "Labels applied to the registration for cost and ownership reporting."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Fully-qualified resource id of the registration (projects/<p>/locations/global/registrations/<domain>)."
  value       = google_clouddomains_registration.this.id
}

output "name" {
  description = "Resource name of the registration."
  value       = google_clouddomains_registration.this.name
}

output "domain_name" {
  description = "The registered domain name."
  value       = google_clouddomains_registration.this.domain_name
}

output "state" {
  description = "Lifecycle state of the registration (e.g. ACTIVE, REGISTRATION_PENDING, REGISTRATION_FAILED)."
  value       = google_clouddomains_registration.this.state
}

output "expire_time" {
  description = "Timestamp at which the current registration term expires. Drive renewal alerting from this."
  value       = google_clouddomains_registration.this.expire_time
}

output "register_failure_reason" {
  description = "If registration failed, the reason reported by the registrar; empty otherwise."
  value       = google_clouddomains_registration.this.register_failure_reason
}

output "managed_name_servers" {
  description = "Name servers the registrar reports for the domain. Useful when Cloud Domains assigns Google-managed DNS."
  value       = google_clouddomains_registration.this.dns_settings
}

output "issues" {
  description = "List of issues on the registration (e.g. CONTACT_SUPPORT, UNVERIFIED_EMAIL) that need attention."
  value       = google_clouddomains_registration.this.issues
}

output "registration_gcp_resource" {
  description = "The full google_clouddomains_registration object for advanced composition."
  value       = google_clouddomains_registration.this
}

How to use it

Register kloudvin.dev privately, lock it against transfer, enable auto-renewal, acknowledge the .dev HSTS notice, and delegate DNS to an existing Cloud DNS zone’s name servers:

module "cloud_domains" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-domains?ref=v1.0.0"

  project_id  = "kloudvin-prod-domains"
  domain_name = "kloudvin.dev"

  # .dev is on the HSTS preload list — this acknowledgement is mandatory.
  domain_notices = ["HSTS_PRELOADED"]

  # Attest to the live first-year price Cloud Domains quotes for .dev.
  yearly_price_units    = "12"
  yearly_price_currency = "USD"

  contact_privacy          = "PRIVATE_CONTACT_DATA"
  transfer_lock_state      = "LOCKED"
  preferred_renewal_method = "AUTOMATIC_RENEWAL"

  # Delegate to the Cloud DNS zone managed elsewhere in the same root.
  custom_name_servers = module.cloud_dns.name_servers

  registrant_contact = {
    email         = "domains@kloudvin.com"
    phone_number  = "+1.5551234567"
    region_code   = "US"
    postal_code   = "94043"
    administrative_area = "CA"
    locality      = "Mountain View"
    organization  = "KloudVin Pte Ltd"
    recipient     = "Domain Administrator"
    address_lines = ["1600 Amphitheatre Parkway"]
  }

  labels = {
    env         = "prod"
    team        = "platform"
    cost-center = "brand-protection"
  }
}

A downstream resource that consumes an output — alert ops when the domain is within 30 days of expiry by feeding expire_time into a Cloud Monitoring log-based comparison, and gate cutover work on the registration actually being ACTIVE:

# Fail fast in plan/apply if the registration didn't land cleanly.
resource "terraform_data" "registration_guard" {
  lifecycle {
    precondition {
      condition     = module.cloud_domains.state == "ACTIVE"
      error_message = "kloudvin.dev is not ACTIVE (state=${module.cloud_domains.state}, reason=${module.cloud_domains.register_failure_reason})."
    }
  }
}

# Surface expiry for renewal alerting / dashboards.
output "kloudvin_dev_expiry" {
  description = "When kloudvin.dev expires — wire this into renewal alerting."
  value       = module.cloud_domains.expire_time
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/cloud_domains/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-domains?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  domain_name = "..."
  yearly_price_units = "..."
  registrant_contact = {}
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/cloud_domains && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string Yes GCP project ID that owns and is billed for the registration.
domain_name string Yes Domain to register, e.g. kloudvin.com (lowercase FQDN, no trailing dot).
yearly_price_units string Yes Whole-currency units of the first-year price you attest to pay; must match the live Cloud Domains quote.
yearly_price_currency string "USD" No ISO 4217 currency code for yearly_price.
contact_privacy string "PRIVATE_CONTACT_DATA" No WHOIS privacy: PRIVATE_CONTACT_DATA, REDACTED_CONTACT_DATA, or PUBLIC_CONTACT_DATA.
transfer_lock_state string "LOCKED" No LOCKED (prevent transfer away) or UNLOCKED.
preferred_renewal_method string "AUTOMATIC_RENEWAL" No AUTOMATIC_RENEWAL or RENEWAL_DISABLED.
custom_name_servers list(string) [] No Name servers to delegate to (e.g. a Cloud DNS zone); at least 2 if set. Mutually exclusive with glue_records.
ds_records list(object({key_tag,algorithm,digest_type,digest})) [] No DNSSEC DS records published at the registry for custom_name_servers.
glue_records list(object({host_name,ipv4_addresses,ipv6_addresses})) [] No Vanity name-server glue records under this domain. Mutually exclusive with custom_name_servers.
registrant_contact object({...}) Yes Registrant (owner) WHOIS contact; reused for admin/technical unless overridden.
admin_contact object({...}) null No Override for the administrative WHOIS contact.
technical_contact object({...}) null No Override for the technical WHOIS contact.
domain_notices list(string) [] No Registrar domain notices to acknowledge; use ["HSTS_PRELOADED"] for .dev/.app.
contact_notices list(string) [] No Contact notices to acknowledge, e.g. for PUBLIC_CONTACT_DATA.
labels map(string) {} No Labels for cost and ownership reporting.

Outputs

Name Description
id Fully-qualified resource id (projects/<p>/locations/global/registrations/<domain>).
name Resource name of the registration.
domain_name The registered domain name.
state Lifecycle state (ACTIVE, REGISTRATION_PENDING, REGISTRATION_FAILED, …).
expire_time Timestamp the current term expires; drive renewal alerting from this.
register_failure_reason Registrar-reported failure reason, if the registration failed.
managed_name_servers The dns_settings the registrar reports (useful when Google-managed DNS is assigned).
issues Issues needing attention (e.g. UNVERIFIED_EMAIL, CONTACT_SUPPORT).
registration_gcp_resource The full google_clouddomains_registration object for advanced composition.

Enterprise scenario

KloudVin consolidates its brand-protection portfolio — kloudvin.com, kloudvin.dev, and a dozen defensive TLDs — into a dedicated kloudvin-prod-domains project so every domain is billed to one cost center, governed by IAM, and audited centrally. The platform team instantiates this module once per domain from a for_each over a portfolio map, each with contact_privacy = "PRIVATE_CONTACT_DATA", transfer_lock_state = "LOCKED", and preferred_renewal_method = "AUTOMATIC_RENEWAL", and delegates DNS via custom_name_servers = module.cloud_dns[domain].name_servers so the registrar points straight at the matching Cloud DNS zone in the same plan. A scheduled job reads each module’s expire_time output into Cloud Monitoring and pages the team 30 days out, so no business-critical domain can silently lapse or be transferred away — and every change to privacy, locks, or contacts shows up as a reviewable diff.

Best practices

TerraformGCPCloud DomainsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading