IaC GCP

Terraform Module: GCP Certificate Manager — Google-managed TLS at scale with DNS authorization

Quick take — A reusable Terraform module for GCP Certificate Manager: provision Google-managed and self-managed TLS certificates, DNS authorizations, maps, and map entries for global load balancers — wired for hashicorp/google ~> 5.0. 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 "certificate_manager" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-certificate-manager?ref=v1.0.0"

  project_id  = "..."  # GCP project ID where Certificate Manager resources are …
  name_prefix = "..."  # Lowercase RFC-1035 prefix used to build cert/map/entry/…
}

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

What this module is

GCP Certificate Manager is Google Cloud’s service for provisioning, storing, and serving TLS certificates to fronting infrastructure — primarily global external Application Load Balancers, but also Cloud Service Mesh and cross-region internal ALBs. It supersedes the older “SSL certificate” resources (google_compute_ssl_certificate / google_compute_managed_ssl_certificate) and, crucially, lifts the per-load-balancer cap of 15 certificates: a single Certificate Manager certificate map can serve thousands of SNI hostnames behind one target proxy. Google-managed certs auto-renew, support wildcard SANs (via DNS authorization), and validate without you having to point traffic at the LB first.

The catch is that a production deployment is never one resource. A real google_certificate_manager_certificate almost always travels with a DNS authorization (so Google can prove domain control via a CNAME before any traffic exists), a certificate map (the SNI routing table), and certificate map entries (which hostname maps to which cert, plus a primary fallback). Wiring those four resources together by hand — with the correct dependency order, the right managed vs self-managed block, and the DNS records that the authorization emits — is repetitive and easy to get subtly wrong. This module wraps the whole set behind a small, validated variable surface so every load balancer in your estate provisions TLS the same way.

When to use it

If you only ever serve a single apex domain behind a classic load balancer and never need wildcards, a plain google_compute_managed_ssl_certificate may be simpler. The moment you need scale, wildcards, or pre-validation, Certificate Manager is the right tool.

Module structure

terraform-module-gcp-certificate-manager/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # certificate, DNS authorization, map, map entries
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # ids, names, and the DNS CNAME records to publish
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # DNS authorizations are only meaningful for Google-managed certs.
  dns_authorizations = var.type == "managed" ? var.dns_authorizations : {}

  # Build the certificate map only when requested.
  create_map = var.create_certificate_map

  # Map entries are keyed by hostname; the "primary" entry is mutually
  # exclusive with hostname and acts as the SNI fallback.
  hostname_entries = local.create_map ? var.certificate_map_entries : {}
}

# ---------------------------------------------------------------------------
# DNS authorizations: Google emits a CNAME you publish so it can prove
# domain control. Required for managed wildcard certs and for pre-validation.
# ---------------------------------------------------------------------------
resource "google_certificate_manager_dns_authorization" "this" {
  for_each = local.dns_authorizations

  name        = "${var.name_prefix}-dnsauth-${each.key}"
  project     = var.project_id
  location    = var.location
  description = each.value.description
  domain      = each.value.domain
  type        = each.value.type # FIXED_RECORD or PER_PROJECT_RECORD
  labels      = var.labels
}

# ---------------------------------------------------------------------------
# The certificate itself: exactly one of `managed` or `self_managed`.
# ---------------------------------------------------------------------------
resource "google_certificate_manager_certificate" "this" {
  name        = "${var.name_prefix}-cert"
  project     = var.project_id
  location    = var.location
  description = var.description
  scope       = var.scope # DEFAULT, EDGE_CACHE, ALL_REGIONS, or CLIENT_AUTH
  labels      = var.labels

  dynamic "managed" {
    for_each = var.type == "managed" ? [1] : []
    content {
      domains = var.managed_domains

      # Attach every DNS authorization we created so wildcards and
      # pre-validation work.
      dns_authorizations = [
        for k, auth in google_certificate_manager_dns_authorization.this : auth.id
      ]

      # Optional: pin an existing CA pool / issuance config for private CAs.
      issuance_config = var.issuance_config
    }
  }

  dynamic "self_managed" {
    for_each = var.type == "self_managed" ? [1] : []
    content {
      pem_certificate = var.self_managed_pem_certificate
      pem_private_key = var.self_managed_pem_private_key
    }
  }

  lifecycle {
    create_before_destroy = true
  }
}

# ---------------------------------------------------------------------------
# Certificate map: the SNI routing table a target proxy references.
# ---------------------------------------------------------------------------
resource "google_certificate_manager_certificate_map" "this" {
  count = local.create_map ? 1 : 0

  name        = "${var.name_prefix}-map"
  project     = var.project_id
  description = var.description
  labels      = var.labels
}

# ---------------------------------------------------------------------------
# Map entries: bind hostnames (or the primary fallback) to this cert.
# ---------------------------------------------------------------------------
resource "google_certificate_manager_certificate_map_entry" "this" {
  for_each = local.hostname_entries

  name        = "${var.name_prefix}-entry-${each.key}"
  project     = var.project_id
  description = each.value.description
  map         = google_certificate_manager_certificate_map.this[0].name
  labels      = var.labels

  # A map entry is either matched by hostname OR designated PRIMARY.
  hostname  = each.value.matcher == null ? each.value.hostname : null
  matcher   = each.value.matcher # PRIMARY for the SNI fallback, else null

  certificates = [google_certificate_manager_certificate.this.id]
}
# variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID where Certificate Manager resources are created."
}

variable "name_prefix" {
  type        = string
  description = "Prefix for all resource names (e.g. 'prod-platform'). Lowercase, used to build cert/map/entry names."

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name_prefix))
    error_message = "name_prefix must be lowercase, start with a letter, and contain only letters, digits, and hyphens (RFC 1035)."
  }
}

variable "location" {
  type        = string
  description = "Location for the certificate and DNS authorizations. Use 'global' for global ALBs, or a region for regional/cross-region internal ALBs."
  default     = "global"
}

variable "description" {
  type        = string
  description = "Human-readable description applied to the certificate and map."
  default     = "Managed by Terraform — KloudVin Certificate Manager module."
}

variable "labels" {
  type        = map(string)
  description = "Labels applied to certificate, map, entries, and DNS authorizations."
  default     = {}
}

variable "type" {
  type        = string
  description = "Certificate provisioning type: 'managed' (Google-managed, auto-renew) or 'self_managed' (you supply the PEM)."
  default     = "managed"

  validation {
    condition     = contains(["managed", "self_managed"], var.type)
    error_message = "type must be either 'managed' or 'self_managed'."
  }
}

variable "scope" {
  type        = string
  description = "Certificate scope: DEFAULT (global ALB), EDGE_CACHE (Media CDN), ALL_REGIONS (cross-region internal ALB), or CLIENT_AUTH (mTLS trust)."
  default     = "DEFAULT"

  validation {
    condition     = contains(["DEFAULT", "EDGE_CACHE", "ALL_REGIONS", "CLIENT_AUTH"], var.scope)
    error_message = "scope must be one of DEFAULT, EDGE_CACHE, ALL_REGIONS, or CLIENT_AUTH."
  }
}

# --- Managed certificate inputs -------------------------------------------
variable "managed_domains" {
  type        = list(string)
  description = "FQDNs (and wildcards like '*.api.example.com') for the Google-managed certificate. Wildcards require a matching DNS authorization."
  default     = []

  validation {
    condition     = length(var.managed_domains) <= 100
    error_message = "A single managed certificate supports at most 100 domains."
  }
}

variable "dns_authorizations" {
  type = map(object({
    domain      = string
    type        = optional(string, "FIXED_RECORD")
    description = optional(string, "DNS authorization managed by Terraform.")
  }))
  description = "DNS authorizations keyed by a short id. Each emits a CNAME to publish. Required for wildcard and pre-validated managed certs."
  default     = {}

  validation {
    condition = alltrue([
      for a in values(var.dns_authorizations) :
      contains(["FIXED_RECORD", "PER_PROJECT_RECORD"], a.type)
    ])
    error_message = "Each dns_authorization type must be FIXED_RECORD or PER_PROJECT_RECORD."
  }
}

variable "issuance_config" {
  type        = string
  description = "Optional certificate issuance config resource ID for issuing from a private CA (CA Service) instead of the public Google CA."
  default     = null
}

# --- Self-managed certificate inputs --------------------------------------
variable "self_managed_pem_certificate" {
  type        = string
  description = "PEM-encoded certificate chain (only when type = 'self_managed'). Pass via a secure source, never hardcode."
  default     = null
  sensitive   = true
}

variable "self_managed_pem_private_key" {
  type        = string
  description = "PEM-encoded private key (only when type = 'self_managed'). Pass via a secure source, never hardcode."
  default     = null
  sensitive   = true
}

# --- Certificate map inputs -----------------------------------------------
variable "create_certificate_map" {
  type        = bool
  description = "Whether to create a certificate map and map entries for SNI-based serving on a target proxy."
  default     = true
}

variable "certificate_map_entries" {
  type = map(object({
    hostname    = optional(string)
    matcher     = optional(string) # set to "PRIMARY" for the SNI fallback entry
    description = optional(string, "Map entry managed by Terraform.")
  }))
  description = "Certificate map entries keyed by a short id. Provide either 'hostname' or matcher = 'PRIMARY' (never both) per entry."
  default     = {}

  validation {
    condition = alltrue([
      for e in values(var.certificate_map_entries) :
      (e.matcher == "PRIMARY") != (e.hostname != null && e.hostname != "")
    ])
    error_message = "Each map entry must set exactly one of 'hostname' or matcher = 'PRIMARY'."
  }
}
# outputs.tf

output "certificate_id" {
  description = "Full resource ID of the certificate (projects/.../certificates/...)."
  value       = google_certificate_manager_certificate.this.id
}

output "certificate_name" {
  description = "Short name of the certificate."
  value       = google_certificate_manager_certificate.this.name
}

output "certificate_map_id" {
  description = "Full resource ID of the certificate map, or null when not created. Reference this from a target HTTPS proxy."
  value       = local.create_map ? google_certificate_manager_certificate_map.this[0].id : null
}

output "certificate_map_name" {
  description = "Short name of the certificate map, or null when not created."
  value       = local.create_map ? google_certificate_manager_certificate_map.this[0].name : null
}

output "dns_authorization_records" {
  description = "CNAME records (name -> data) you must publish in DNS so Google can validate domain control. Empty for self-managed certs."
  value = {
    for k, auth in google_certificate_manager_dns_authorization.this :
    k => {
      domain      = auth.domain
      record_name = auth.dns_resource_record[0].name
      record_type = auth.dns_resource_record[0].type
      record_data = auth.dns_resource_record[0].data
    }
  }
}

output "map_entry_ids" {
  description = "Map of entry id -> certificate map entry resource ID."
  value       = { for k, e in google_certificate_manager_certificate_map_entry.this : k => e.id }
}

How to use it

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

  project_id  = "kloudvin-prod-edge"
  name_prefix = "prod-platform"
  location    = "global"
  scope       = "DEFAULT"
  type        = "managed"

  # Apex + wildcard. The wildcard mandates a DNS authorization.
  managed_domains = [
    "kloudvin.com",
    "*.api.kloudvin.com",
  ]

  dns_authorizations = {
    apex = {
      domain = "kloudvin.com"
    }
    api = {
      domain = "api.kloudvin.com"
      type   = "PER_PROJECT_RECORD"
    }
  }

  # SNI routing: a PRIMARY fallback plus explicit hostnames.
  certificate_map_entries = {
    fallback = {
      matcher = "PRIMARY"
    }
    apex = {
      hostname = "kloudvin.com"
    }
    api = {
      hostname = "v1.api.kloudvin.com"
    }
  }

  labels = {
    team        = "platform"
    environment = "prod"
    managed_by  = "terraform"
  }
}

# Downstream: attach the map to a global HTTPS target proxy by name.
resource "google_compute_target_https_proxy" "default" {
  name             = "prod-platform-https-proxy"
  project          = "kloudvin-prod-edge"
  url_map          = google_compute_url_map.default.id
  certificate_map  = "//certificatemanager.googleapis.com/${module.certificate_manager.certificate_map_id}"
}

# Downstream: publish the validation CNAMEs into a Cloud DNS managed zone.
resource "google_dns_record_set" "cert_validation" {
  for_each = module.certificate_manager.dns_authorization_records

  project      = "kloudvin-prod-edge"
  managed_zone = "kloudvin-com"
  name         = each.value.record_name
  type         = each.value.record_type
  ttl          = 300
  rrdatas      = [each.value.record_data]
}

Note the certificate_map attribute on the target proxy takes the //certificatemanager.googleapis.com/... self-link form, so the module’s certificate_map_id (already a full resource path) is interpolated into it. Once the validation CNAMEs from dns_authorization_records resolve, the managed certificate transitions to ACTIVE and begins serving.

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/certificate_manager/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name_prefix = "..."
}

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

cd live/prod/certificate_manager && 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 where Certificate Manager resources are created.
name_prefix string Yes Lowercase RFC-1035 prefix used to build cert/map/entry/auth names.
location string "global" No global for global ALBs, or a region for regional/cross-region internal ALBs.
description string "Managed by Terraform — KloudVin Certificate Manager module." No Description applied to the certificate and map.
labels map(string) {} No Labels applied to all created resources.
type string "managed" No managed (Google-managed, auto-renew) or self_managed (you supply PEM).
scope string "DEFAULT" No DEFAULT, EDGE_CACHE, ALL_REGIONS, or CLIENT_AUTH.
managed_domains list(string) [] No FQDNs/wildcards for the managed cert (max 100); wildcards need a DNS authorization.
dns_authorizations map(object) {} No DNS authorizations keyed by id; each emits a CNAME. type is FIXED_RECORD or PER_PROJECT_RECORD.
issuance_config string null No Issuance config resource ID for issuing from a private CA (CA Service).
self_managed_pem_certificate string (sensitive) null No PEM certificate chain when type = "self_managed".
self_managed_pem_private_key string (sensitive) null No PEM private key when type = "self_managed".
create_certificate_map bool true No Whether to create a certificate map and entries for SNI serving.
certificate_map_entries map(object) {} No Entries keyed by id; each sets exactly one of hostname or matcher = PRIMARY.

Outputs

Name Description
certificate_id Full resource ID of the certificate (projects/.../certificates/...).
certificate_name Short name of the certificate.
certificate_map_id Full resource ID of the certificate map (or null), for the target HTTPS proxy.
certificate_map_name Short name of the certificate map, or null when not created.
dns_authorization_records Map of id → CNAME (record_name, record_type, record_data) to publish for domain validation.
map_entry_ids Map of entry id → certificate map entry resource ID.

Enterprise scenario

A retail SaaS runs a global storefront on a single global external Application Load Balancer serving 400+ merchant vanity domains plus a *.shops.kloudvin.com wildcard for self-serve tenants. With classic SSL certs they were blocked by the 15-cert-per-proxy ceiling and a manual renewal runbook. They adopt this module once per environment: the wildcard tenant traffic is covered by a single DNS-authorized managed cert with a PRIMARY map entry as the SNI fallback, while onboarding a premium merchant’s custom domain becomes a one-line certificate_map_entries addition in a PR — Terraform creates the authorization CNAME (surfaced via dns_authorization_records for the merchant to publish), the cert auto-validates and renews, and no load-balancer reconfiguration is ever needed.

Best practices

TerraformGCPCertificate ManagerModuleIaC
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