IaC GCP

Terraform Module: GCP Cloud DNS — One Reusable Zone Pattern for Public and Private Resolution

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:

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:

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 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_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

TerraformGCPCloud DNSModuleIaC
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