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:
- Registers a domain with a chosen privacy mode (
PRIVATE_CONTACT_DATA,REDACTED_CONTACT_DATA, orPUBLIC_CONTACT_DATA) and a complete WHOIS contact profile (registrant / admin / technical) built from variables, not copy-pasted postal addresses. - Configures DNS either as custom name servers (point the domain at an existing Cloud DNS zone or external NS) or with glue records for vanity name servers under the domain itself.
- Sets the transfer lock and auto-renewal policy explicitly, so a production domain can’t be transferred away or allowed to lapse by accident.
- Surfaces the registration state, expiry time, managed name servers, and any issues as outputs so downstream code (and humans) can react to
register_failure_reasonor pending verification.
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:
- You want domains registered and renewed inside GCP, billed to a project, governed by IAM, and captured in Cloud Audit Logs rather than scattered across personal registrar accounts.
- You run DNS in Cloud DNS and want the registrar’s name-server delegation wired straight to your managed zone — registration and resolution in the same Terraform graph.
- You need consistent WHOIS privacy and transfer locks across a portfolio of domains for brand-protection or compliance reasons, and want drift on those settings to show up in a plan.
- You want auto-renewal as policy, so business-critical domains can never silently expire, with the renewal method visible and reviewable in code.
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 config — live/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 config — live/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
- Treat every
applyas a real purchase and gate it. A newgoogle_clouddomains_registrationcharges your billing account for a full year on create, and you can’t cheaply undo it. Keep registrations out of unattended pipelines, require manual approval on the plan, and setprevent_destroy = trueon crown-jewel domains so an errantterraform destroycan’t drop a live name. - Make
yearly_pricematch the live quote exactly, per TLD. Theunits/currency_codeyou attest to must equal the price Cloud Domains currently quotes for that TLD and your billing currency, or the apply fails. Confirm the number (gcloud domains registrations get-iam-policy/ the console availability check) before bumping the variable, and remember prices differ by TLD —.comis not.dev. - Default to
PRIVATE_CONTACT_DATAand lock transfers. Keep WHOIS privacy on andtransfer_lock_state = "LOCKED"for production domains to reduce spam and prevent hostile transfers; only drop toPUBLIC_CONTACT_DATA(with the matchingcontact_noticesacknowledgement) when a TLD or policy genuinely requires public WHOIS. - Always enable
AUTOMATIC_RENEWALfor anything that serves traffic. An expired domain takes your site, email (MX), and certificates down hard. Setpreferred_renewal_method = "AUTOMATIC_RENEWAL"and alert on theexpire_timeoutput as a backstop rather than relying on a calendar reminder. - Acknowledge TLD notices up front for HTTPS-only domains. Registering
.dev,.app, or other HSTS-preloaded TLDs requiresdomain_notices = ["HSTS_PRELOADED"]; omitting it fails the registration, and the implication (the domain only works over HTTPS) should be a deliberate, documented choice. - Wire DNS in the same graph and label for ops. Feed
custom_name_serversfrom your Cloud DNS module’sname_serversso registration and resolution can’t drift apart, publishds_recordswhen the zone is DNSSEC-signed, and drive cost/ownership reporting fromlabels(env,team,cost-center) rather than guessing who owns which domain.