Quick take — Build a production-ready Terraform module for GCP Cloud DNS using google_dns_managed_zone — handle public zones, private VPC-scoped zones, DNSSEC, record sets, and clean outputs from a single 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_dns" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-dns?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the managed zone.
zone_name = "..." # Resource name of the zone; lowercase letter start, lett…
dns_name = "..." # FQDN namespace with trailing dot, e.g. `kloudvin.com.`.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Cloud DNS is Google Cloud’s managed, authoritative DNS service. It runs on Google’s anycast name servers (the ns-cloud-*.googledomains.com set), serves both internet-facing public zones and VPC-scoped private zones, and bills per managed zone plus per million queries. The unit of management is the managed zone — a container for the resource record sets (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS) that belong to one DNS namespace such as kloudvin.com. or an internal corp.kloudvin.internal..
Hand-clicking zones in the console works for a single domain, but it falls apart the moment you have public and private variants of the same name, DNSSEC to toggle, a fleet of records to keep in sync, and multiple environments. Wrapping google_dns_managed_zone in a module gives you one opinionated interface that:
- Creates a public or private zone from a single
visibilityflag, attaching the right VPC networks when private. - Optionally enables DNSSEC with a sane signing config for public zones.
- Manages an arbitrary map of record sets (
google_dns_record_set) alongside the zone so records ship with the zone. - Emits the name servers, zone id, and zone name as outputs so downstream code (and your registrar) can wire delegation without copy-paste.
The result: every zone in your org is created the same way, DNSSEC and logging are not forgotten, and a record set is a map entry instead of a console click.
When to use it
Reach for this module when:
- You manage one or more DNS namespaces in GCP and want them codified, reviewed, and reproducible across
dev/stg/prod. - You run split-horizon DNS — a public
kloudvin.comzone for the internet and a privatekloudvin.comzone resolving the same name to internal IPs inside your VPCs. - You need DNSSEC turned on for public zones (compliance, anti-spoofing) without remembering the signing-config dance each time.
- You want record sets to live next to the zone in code so a new
Arecord is a pull request, not a ticket.
Skip it (or extend it) if you need forwarding zones, peering zones, or reverse-lookup / service-directory zones as a first-class feature — those use different google_dns_managed_zone blocks (forwarding_config, peering_config) and are intentionally out of scope for this public/private + record-set module. The structure below makes adding them straightforward.
Module structure
terraform-module-gcp-cloud-dns/
├── versions.tf # provider + Terraform version pins
├── main.tf # google_dns_managed_zone + google_dns_record_set
├── variables.tf # var-driven inputs with validations
└── outputs.tf # zone id/name + name servers
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# DNSSEC only makes sense for public zones; force it off for private.
dnssec_state = var.visibility == "public" && var.enable_dnssec ? "on" : "off"
}
resource "google_dns_managed_zone" "this" {
project = var.project_id
name = var.zone_name
dns_name = var.dns_name
description = var.description
visibility = var.visibility
labels = var.labels
# Attach VPC networks only for private zones.
dynamic "private_visibility_config" {
for_each = var.visibility == "private" ? [1] : []
content {
dynamic "networks" {
for_each = var.private_visibility_networks
content {
network_url = networks.value
}
}
}
}
# DNSSEC signing — public zones only.
dynamic "dnssec_config" {
for_each = local.dnssec_state == "on" ? [1] : []
content {
state = "on"
non_existence = "nsec3"
default_key_specs {
algorithm = var.dnssec_algorithm
key_type = "keySigning"
key_length = 2048
}
default_key_specs {
algorithm = var.dnssec_algorithm
key_type = "zoneSigning"
key_length = 1024
}
}
}
# Optional query logging (public zones only in Cloud DNS).
dynamic "cloud_logging_config" {
for_each = var.visibility == "public" && var.enable_logging ? [1] : []
content {
enable_logging = true
}
}
force_destroy = var.force_destroy
}
resource "google_dns_record_set" "this" {
for_each = var.record_sets
project = var.project_id
managed_zone = google_dns_managed_zone.this.name
# Names are relative entries joined to the zone's dns_name; "@" means apex.
name = each.value.name == "@" ? var.dns_name : "${each.value.name}.${var.dns_name}"
type = each.value.type
ttl = each.value.ttl
rrdatas = each.value.rrdatas
}
variables.tf
variable "project_id" {
description = "GCP project ID that owns the managed zone."
type = string
}
variable "zone_name" {
description = "Resource name of the managed zone (lowercase letters, digits, hyphens; must start with a letter)."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,62}$", var.zone_name))
error_message = "zone_name must start with a lowercase letter and contain only lowercase letters, digits, and hyphens (max 63 chars)."
}
}
variable "dns_name" {
description = "Fully-qualified DNS namespace for the zone, WITH a trailing dot (e.g. \"kloudvin.com.\")."
type = string
validation {
condition = can(regex("\\.$", var.dns_name))
error_message = "dns_name must be fully qualified and end with a trailing dot, e.g. \"kloudvin.com.\"."
}
}
variable "description" {
description = "Human-readable description shown in the console and API."
type = string
default = "Managed by Terraform"
}
variable "visibility" {
description = "Zone visibility: \"public\" (internet-authoritative) or \"private\" (VPC-scoped)."
type = string
default = "public"
validation {
condition = contains(["public", "private"], var.visibility)
error_message = "visibility must be either \"public\" or \"private\"."
}
}
variable "private_visibility_networks" {
description = "List of VPC self_link URLs the zone resolves on. Required (non-empty) when visibility = \"private\"."
type = list(string)
default = []
validation {
condition = alltrue([for n in var.private_visibility_networks : can(regex("^https://", n))])
error_message = "Each network must be a full self_link URL beginning with https:// (use google_compute_network.x.self_link)."
}
}
variable "enable_dnssec" {
description = "Enable DNSSEC signing. Honored only for public zones; ignored for private."
type = bool
default = true
}
variable "dnssec_algorithm" {
description = "DNSSEC signing algorithm for both key-signing and zone-signing keys."
type = string
default = "rsasha256"
validation {
condition = contains(["rsasha1", "rsasha256", "rsasha512", "ecdsap256sha256", "ecdsap384sha384"], var.dnssec_algorithm)
error_message = "dnssec_algorithm must be one of: rsasha1, rsasha256, rsasha512, ecdsap256sha256, ecdsap384sha384."
}
}
variable "enable_logging" {
description = "Enable Cloud DNS query logging (public zones only) to Cloud Logging."
type = bool
default = false
}
variable "record_sets" {
description = <<-EOT
Map of resource record sets to create in the zone. Key is an arbitrary label.
"name" is the relative record name; use "@" for the zone apex.
Example:
{
www = { name = "www", type = "A", ttl = 300, rrdatas = ["203.0.113.10"] }
mx = { name = "@", type = "MX", ttl = 3600, rrdatas = ["10 mail.kloudvin.com."] }
}
EOT
type = map(object({
name = string
type = string
ttl = number
rrdatas = list(string)
}))
default = {}
validation {
condition = alltrue([
for r in values(var.record_sets) :
contains(["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA", "PTR", "SOA"], r.type)
])
error_message = "Each record set type must be a valid DNS record type (A, AAAA, CNAME, MX, TXT, SRV, NS, CAA, PTR, SOA)."
}
validation {
condition = alltrue([for r in values(var.record_sets) : r.ttl >= 0 && r.ttl <= 86400])
error_message = "Each record set ttl must be between 0 and 86400 seconds."
}
}
variable "labels" {
description = "Labels applied to the managed zone."
type = map(string)
default = {}
}
variable "force_destroy" {
description = "If true, `terraform destroy` removes the zone even if it still contains record sets."
type = bool
default = false
}
outputs.tf
output "id" {
description = "Fully-qualified resource id of the managed zone."
value = google_dns_managed_zone.this.id
}
output "zone_name" {
description = "Resource name of the managed zone (use as managed_zone in google_dns_record_set)."
value = google_dns_managed_zone.this.name
}
output "dns_name" {
description = "DNS namespace of the zone (with trailing dot)."
value = google_dns_managed_zone.this.dns_name
}
output "name_servers" {
description = "Authoritative name servers for the zone. For public zones, set these as NS/delegation records at your registrar."
value = google_dns_managed_zone.this.name_servers
}
output "managed_zone_gcp_resource" {
description = "The google_dns_managed_zone resource object for advanced composition."
value = google_dns_managed_zone.this
}
output "record_set_ids" {
description = "Map of record-set keys to their resource ids."
value = { for k, r in google_dns_record_set.this : k => r.id }
}
How to use it
A public zone for kloudvin.com with DNSSEC on, query logging enabled, and a handful of records:
module "cloud_dns" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-dns?ref=v1.0.0"
project_id = "kloudvin-prod-dns"
zone_name = "kloudvin-com-public"
dns_name = "kloudvin.com."
visibility = "public"
description = "Public authoritative zone for kloudvin.com"
enable_dnssec = true
enable_logging = true
record_sets = {
apex_a = {
name = "@"
type = "A"
ttl = 300
rrdatas = ["203.0.113.10"]
}
www_cname = {
name = "www"
type = "CNAME"
ttl = 300
rrdatas = ["kloudvin.com."]
}
mx = {
name = "@"
type = "MX"
ttl = 3600
rrdatas = ["10 aspmx.l.google.com."]
}
spf = {
name = "@"
type = "TXT"
ttl = 3600
rrdatas = ["\"v=spf1 include:_spf.google.com ~all\""]
}
}
labels = {
env = "prod"
team = "platform"
}
}
A downstream resource that consumes an output — here, telling the registrar/delegation which name servers to use, and seeding a Cloud Build trigger notification:
# Surface the Google-assigned name servers so you (or another module)
# can configure NS delegation at the domain registrar.
output "registrar_delegation_nameservers" {
description = "Paste these into your registrar's NS records for kloudvin.com."
value = module.cloud_dns.name_servers
}
# Example: add an ACME/cert-manager challenge record into the same zone later
# by reusing the zone name output as the managed_zone reference.
resource "google_dns_record_set" "acme_challenge" {
project = "kloudvin-prod-dns"
managed_zone = module.cloud_dns.zone_name
name = "_acme-challenge.${module.cloud_dns.dns_name}"
type = "TXT"
ttl = 60
rrdatas = ["\"<acme-token-managed-elsewhere>\""]
}
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_dns/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-dns?ref=v1.0.0"
}
inputs = {
project_id = "..."
zone_name = "..."
dns_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloud_dns && 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 the managed zone. |
zone_name |
string |
— | Yes | Resource name of the zone; lowercase letter start, letters/digits/hyphens, max 63 chars. |
dns_name |
string |
— | Yes | FQDN namespace with trailing dot, e.g. kloudvin.com.. |
description |
string |
"Managed by Terraform" |
No | Description shown in console/API. |
visibility |
string |
"public" |
No | public (internet) or private (VPC-scoped). |
private_visibility_networks |
list(string) |
[] |
No | VPC self_link URLs the private zone resolves on; required when visibility = "private". |
enable_dnssec |
bool |
true |
No | Enable DNSSEC signing; honored only for public zones. |
dnssec_algorithm |
string |
"rsasha256" |
No | Signing algorithm for KSK and ZSK. |
enable_logging |
bool |
false |
No | Enable Cloud DNS query logging (public zones only). |
record_sets |
map(object({name,type,ttl,rrdatas})) |
{} |
No | Map of record sets to create; name = "@" targets the apex. |
labels |
map(string) |
{} |
No | Labels applied to the managed zone. |
force_destroy |
bool |
false |
No | Allow destroy even when record sets remain. |
Outputs
| Name | Description |
|---|---|
id |
Fully-qualified resource id of the managed zone. |
zone_name |
Resource name of the zone; use as managed_zone in google_dns_record_set. |
dns_name |
DNS namespace of the zone (with trailing dot). |
name_servers |
Authoritative name servers; set these as NS delegation at your registrar for public zones. |
managed_zone_gcp_resource |
The full google_dns_managed_zone object for advanced composition. |
record_set_ids |
Map of record-set keys to their resource ids. |
Enterprise scenario
KloudVin runs split-horizon DNS for kloudvin.com. The platform team instantiates this module twice from the same root: one visibility = "public" zone (DNSSEC on, query logging to Cloud Logging) whose name_servers output is delegated at the registrar and serves 203.0.113.10 to the internet, and one visibility = "private" zone for the identical kloudvin.com. namespace, attached to the production and shared-services VPC self_links, that resolves the same hostnames to internal 10.x load-balancer IPs. Internal services reach api.kloudvin.com over private RFC 1918 addresses while external users hit the public IP — and both zones, plus every record, are reviewed as code and promoted dev → stg → prod through the same pipeline.
Best practices
- Always end
dns_namewith a trailing dot and use@for the apex. Cloud DNS treats names as fully qualified; a missing dot or a hand-builtnamesilently creates the wrong record. The module’s validation and thename == "@"apex handling guard against the most common mistakes. - Turn on DNSSEC for every public zone, then complete the chain of trust. Enabling it here is only half the job — you must also publish the resulting DS record at your registrar. Leaving DNSSEC “on” in Cloud DNS without the parent DS record gives no protection and can break validation, so treat the registrar step as part of the rollout.
- Keep TTLs intentional and low before cutovers. Use short TTLs (60–300s) on records you’re about to migrate so changes propagate fast, then raise stable records (NS, MX, SPF) back to 3600s+ to cut query volume and DNS cost. Cloud DNS bills per million queries, so unnecessarily low TTLs on hot records inflate the bill.
- Scope private zones to the minimum set of VPCs. Pass only the
self_links that genuinely need internal resolution viaprivate_visibility_networks; over-attaching leaks internal names into networks that shouldn’t see them and complicates split-horizon reasoning. - Name zones for humans and the registrar, label for ops. Use a descriptive
zone_namelikekloudvin-com-public/kloudvin-com-private(the resource name can’t be the bare domain anyway), and drive cost/ownership reporting fromlabels(env,team,cost-center) rather than the description. - Guard destruction with
force_destroy = falsein production. Keep the default so a zone holding live records can’t be wiped by an errantterraform destroy; flip it totrueonly for throwaway ephemeral environments.