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
- You front public traffic with a global external Application Load Balancer and want SNI for many hostnames without hitting the 15-certificate limit of classic SSL certs.
- You need wildcard certificates (
*.api.example.com) — these require DNS authorization, which only Certificate Manager supports for Google-managed certs. - You want certificates that validate and renew before cutover, using
LOAD_BALANCING_DNS_AUTHORIZATIONso the cert isACTIVEbefore you swing DNS to the LB. - You operate a multi-tenant or multi-domain platform where hostnames are added and removed frequently, and you want each addition to be a single map entry rather than an LB re-config.
- You are standardizing TLS across many teams and want naming, labels, location, and the managed/self-managed decision encoded once in a module rather than copy-pasted.
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 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/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
- Pre-validate with DNS authorization before cutover. Always create the
dns_authorizationand publish its CNAME first; the managed cert then reachesACTIVEindependently of where traffic points, so DNS swings to the LB with TLS already serving — no first-request validation race. - Always define a
PRIMARYmap entry. The fallback cert is what gets served when an inbound SNI hostname matches no explicit entry. Omitting it means clients hitting an unlisted host get a TLS handshake failure rather than a graceful default. - Prefer
PER_PROJECT_RECORDauthorizations for many subdomains. A single per-project authorization record can cover all subdomains under a delegated zone, cutting the number of CNAMEs you publish versus oneFIXED_RECORDper domain — cleaner DNS and fewer moving parts at scale. - Keep self-managed PEM material out of state and code. When
type = "self_managed", sourcepem_certificate/pem_private_keyfrom Secret Manager or a Vault data source (the variables are markedsensitive), and lean on Google-managed certs wherever possible so renewal is automatic and no private key ever lives in Terraform state. - Standardize labels and
name_prefixper environment. Consistentenvironment/team/managed_bylabels make Certificate Manager certs filterable in billing and asset inventory, and the RFC-1035-validated prefix prevents the silent name collisions that occur when two LBs reuse a generic cert name. - Mind location and scope alignment. A
globalcert withscope = "DEFAULT"serves global ALBs; cross-region internal ALBs needscope = "ALL_REGIONS", and Media CDN needsEDGE_CACHE. A mismatch validates in plan but fails to attach at the proxy — set them deliberately, not by default.