Quick take — Provision a Google Certificate Authority Service CA pool and a self-signed root or subordinate CA with Terraform — tier, key spec, lifetime, IAM, and CA-cert export wired up for production PKI. 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_authority_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-certificate-authority-service?ref=v1.0.0"
project_id = "..." # Project hosting the CA pool and CA.
location = "..." # Region for the CA pool / CA (CA Service is regional).
name_prefix = "..." # Prefix used to derive pool and CA IDs; validated lowerc…
subject = {} # X.509 subject (`organization`, `common_name` required; …
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Google Certificate Authority Service (CA Service / “privateca”) is a managed private PKI: Google operates the CA software, the signing keys live in Cloud HSM (FIPS 140-2 Level 3), and you get an API to issue and revoke X.509 certificates for internal TLS, mTLS service meshes, workload identity, and device fleets. The two foundational resources are the CA pool (google_privateca_ca_pool) — a logical grouping that holds the issuance policy, tier, and publishing options — and a certificate authority (google_privateca_certificate_authority) that lives inside the pool and actually holds a key and signs.
The reason to wrap these in a reusable module is that a correct root or subordinate CA has a lot of fiddly, easy-to-get-wrong surface area: the tier (ENTERPRISE vs DEVOPS) is immutable and changes the pricing model and revocation behaviour; the key algorithm and the certificate lifetime are set once and effectively permanent for a root; the type (SELF_SIGNED vs SUBORDINATE) decides whether the resource even comes up on its own; and a CA is billed while it exists unless you deliberately keep it STAGED/DISABLED. A module bakes the safe defaults, exposes only the knobs that vary per environment, validates the dangerous ones, and emits the PEM you need to distribute to a trust store — so every team’s internal CA looks the same instead of being a hand-built snowflake.
When to use it
- You need a private root or subordinate CA for internal mTLS, a service mesh (Istio/ASM, Linkerd), or workload-to-workload TLS without paying per-cert for a public CA.
- You are standing up GKE / Cloud Service Mesh and want CA Service as the mesh CA backend instead of the built-in Citadel.
- You run a device or IoT fleet that needs short-lived client certificates issued from a governed pool with an issuance policy.
- You want HSM-backed keys (FIPS 140-2 L3) and an auditable, IAM-controlled issuance path, replacing a fragile OpenSSL-on-a-VM root CA.
- You need to give an app team scoped issuance rights (the Certificate Requester role) on a pool without handing them the signing key.
Reach for a different tool when you need publicly trusted certs (use Google-managed certs on the load balancer, or Certificate Manager / ACME) — CA Service issues certificates trusted only by stores you control.
Module structure
terraform-module-gcp-certificate-authority-service/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# A pool name is immutable; derive it deterministically from the inputs.
ca_pool_id = coalesce(var.ca_pool_id, "${var.name_prefix}-pool")
ca_id = coalesce(var.ca_id, "${var.name_prefix}-ca")
# CA Service prices per-CA differently by tier and only allows certain
# advanced features (issuance policy, CRL publishing) on the ENTERPRISE tier.
is_enterprise = var.tier == "ENTERPRISE"
common_labels = merge(
{
managed-by = "terraform"
component = "private-ca"
},
var.labels,
)
}
resource "google_privateca_ca_pool" "this" {
name = local.ca_pool_id
project = var.project_id
location = var.location
tier = var.tier
labels = local.common_labels
publishing_options {
publish_ca_cert = var.publish_ca_cert
# CRL publishing is an ENTERPRISE-only feature.
publish_crl = local.is_enterprise ? var.publish_crl : false
}
# Baseline issuance policy: cap leaf lifetime and constrain key usage so a
# compromised Certificate Requester cannot mint a long-lived sub-CA.
dynamic "issuance_policy" {
for_each = var.max_issued_cert_lifetime == null ? [] : [1]
content {
maximum_lifetime = var.max_issued_cert_lifetime
baseline_values {
ca_options {
is_ca = false
}
key_usage {
base_key_usage {
digital_signature = true
key_encipherment = true
}
extended_key_usage {
server_auth = true
client_auth = var.allow_client_auth
}
}
}
dynamic "allowed_issuance_modes" {
for_each = var.allow_csr_issuance || var.allow_config_issuance ? [1] : []
content {
allow_csr = var.allow_csr_issuance
allow_config_based_issuance = var.allow_config_issuance
}
}
}
}
}
resource "google_privateca_certificate_authority" "this" {
pool = google_privateca_ca_pool.this.name
certificate_authority_id = local.ca_id
project = var.project_id
location = var.location
type = var.ca_type
lifetime = var.ca_lifetime
deletion_protection = var.deletion_protection
# When destroying, grace period before the CA is permanently purged.
pending_deletion_grace_period = var.pending_deletion_grace_period
# On destroy, also remove the underlying Cloud KMS key version.
ignore_active_certificates_on_deletion = var.ignore_active_certificates_on_deletion
skip_grace_period = var.skip_grace_period
labels = local.common_labels
config {
subject_config {
subject {
organization = var.subject.organization
common_name = var.subject.common_name
country_code = var.subject.country_code
organizational_unit = var.subject.organizational_unit
locality = var.subject.locality
province = var.subject.province
}
}
x509_config {
ca_options {
is_ca = true
max_issuer_path_length = var.max_issuer_path_length
}
key_usage {
base_key_usage {
cert_sign = true
crl_sign = true
}
extended_key_usage {
server_auth = true
}
}
}
}
key_spec {
algorithm = var.key_algorithm
}
}
# Scope issuance to specific principals without exposing the signing key.
resource "google_privateca_ca_pool_iam_member" "requesters" {
for_each = toset(var.certificate_requesters)
ca_pool = google_privateca_ca_pool.this.id
role = "roles/privateca.certificateRequester"
member = each.value
}
variables.tf
variable "project_id" {
description = "Project ID that will host the CA pool and certificate authority."
type = string
}
variable "location" {
description = "Region for the CA pool / CA (e.g. asia-south1, us-central1). CA Service is regional."
type = string
}
variable "name_prefix" {
description = "Prefix used to derive pool and CA IDs when explicit IDs are not given (e.g. 'corp-internal')."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,40}$", var.name_prefix))
error_message = "name_prefix must be lowercase alphanumeric/hyphen, start with a letter, max 41 chars."
}
}
variable "ca_pool_id" {
description = "Explicit CA pool resource ID. Defaults to '<name_prefix>-pool'. Immutable once created."
type = string
default = null
}
variable "ca_id" {
description = "Explicit certificate authority resource ID. Defaults to '<name_prefix>-ca'. Immutable."
type = string
default = null
}
variable "tier" {
description = "CA pool tier. ENTERPRISE supports issuance policy + CRL publishing; DEVOPS is cheaper but has no per-cert tracking/revocation. Immutable."
type = string
default = "ENTERPRISE"
validation {
condition = contains(["ENTERPRISE", "DEVOPS"], var.tier)
error_message = "tier must be ENTERPRISE or DEVOPS."
}
}
variable "ca_type" {
description = "SELF_SIGNED for a root CA, or SUBORDINATE to be signed by a parent CA."
type = string
default = "SELF_SIGNED"
validation {
condition = contains(["SELF_SIGNED", "SUBORDINATE"], var.ca_type)
error_message = "ca_type must be SELF_SIGNED or SUBORDINATE."
}
}
variable "ca_lifetime" {
description = "Lifetime of the CA certificate as a duration in seconds (e.g. '315360000s' = 10 years)."
type = string
default = "315360000s"
validation {
condition = can(regex("^[0-9]+s$", var.ca_lifetime))
error_message = "ca_lifetime must be a duration string in seconds, e.g. '315360000s'."
}
}
variable "key_algorithm" {
description = "Cloud HSM key algorithm for the CA signing key."
type = string
default = "RSA_PKCS1_4096_SHA256"
validation {
condition = contains([
"RSA_PSS_2048_SHA256", "RSA_PSS_3072_SHA256", "RSA_PSS_4096_SHA256",
"RSA_PKCS1_2048_SHA256", "RSA_PKCS1_3072_SHA256", "RSA_PKCS1_4096_SHA256",
"EC_P256_SHA256", "EC_P384_SHA384",
], var.key_algorithm)
error_message = "key_algorithm must be a supported CA Service algorithm (RSA_* or EC_P256/P384)."
}
}
variable "subject" {
description = "X.509 subject for the CA certificate."
type = object({
organization = string
common_name = string
country_code = optional(string)
organizational_unit = optional(string)
locality = optional(string)
province = optional(string)
})
}
variable "max_issuer_path_length" {
description = "Max number of subordinate CAs allowed below this CA in the chain."
type = number
default = 0
}
variable "max_issued_cert_lifetime" {
description = "Cap on leaf-certificate lifetime enforced by the pool issuance policy (seconds), e.g. '7776000s' = 90 days. Null disables the policy. ENTERPRISE only."
type = string
default = "7776000s"
}
variable "allow_client_auth" {
description = "Whether the issuance policy permits clientAuth EKU (needed for mTLS client certs)."
type = bool
default = true
}
variable "allow_csr_issuance" {
description = "Allow certificates to be issued from a raw CSR."
type = bool
default = true
}
variable "allow_config_issuance" {
description = "Allow certificates to be issued from a structured config (no CSR)."
type = bool
default = true
}
variable "publish_ca_cert" {
description = "Publish the CA certificate so issued leaf certs can reference it via AIA."
type = bool
default = true
}
variable "publish_crl" {
description = "Publish a CRL for the pool (ENTERPRISE tier only; ignored on DEVOPS)."
type = bool
default = true
}
variable "certificate_requesters" {
description = "IAM members granted roles/privateca.certificateRequester on the pool (e.g. 'serviceAccount:mesh@proj.iam.gserviceaccount.com')."
type = list(string)
default = []
}
variable "deletion_protection" {
description = "Block 'terraform destroy' of the CA until explicitly disabled."
type = bool
default = true
}
variable "pending_deletion_grace_period" {
description = "Grace period (seconds) the CA stays soft-deleted and restorable before permanent purge."
type = string
default = "2592000s"
}
variable "skip_grace_period" {
description = "If true, purge the CA immediately on delete instead of honouring the grace period. Dangerous for roots."
type = bool
default = false
}
variable "ignore_active_certificates_on_deletion" {
description = "Allow deleting the CA even if it has issued certificates that have not yet expired."
type = bool
default = false
}
variable "labels" {
description = "Additional labels merged onto the pool and CA."
type = map(string)
default = {}
}
outputs.tf
output "ca_pool_id" {
description = "Fully-qualified CA pool resource ID (projects/.../caPools/...)."
value = google_privateca_ca_pool.this.id
}
output "ca_pool_name" {
description = "Short name of the CA pool."
value = google_privateca_ca_pool.this.name
}
output "certificate_authority_id" {
description = "Fully-qualified certificate authority resource ID."
value = google_privateca_certificate_authority.this.id
}
output "certificate_authority_name" {
description = "Short certificate_authority_id of the CA."
value = google_privateca_certificate_authority.this.certificate_authority_id
}
output "ca_state" {
description = "Current state of the CA (ENABLED, STAGED, DISABLED, etc.)."
value = google_privateca_certificate_authority.this.state
}
output "pem_ca_certificate" {
description = "The CA's self-signed PEM certificate (distribute this to client trust stores)."
value = google_privateca_certificate_authority.this.pem_ca_certificate
}
output "pem_ca_certificates_chain" {
description = "Full PEM chain from this CA up to the root (ordered leaf-to-root)."
value = google_privateca_certificate_authority.this.pem_ca_certificates
}
output "tier" {
description = "Resolved tier of the pool (ENTERPRISE or DEVOPS)."
value = google_privateca_ca_pool.this.tier
}
How to use it
module "certificate_authority_service" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-certificate-authority-service?ref=v1.0.0"
project_id = "kv-platform-prod"
location = "asia-south1"
name_prefix = "corp-internal"
tier = "ENTERPRISE"
ca_type = "SELF_SIGNED"
ca_lifetime = "315360000s" # 10 years for the root
key_algorithm = "EC_P384_SHA384"
subject = {
organization = "KloudVin Pvt Ltd"
organizational_unit = "Platform Engineering"
common_name = "KloudVin Internal Root CA"
country_code = "IN"
province = "Karnataka"
locality = "Bengaluru"
}
# 90-day leaf cap for mesh workloads; allow mTLS client certs.
max_issued_cert_lifetime = "7776000s"
allow_client_auth = true
max_issuer_path_length = 1
# Let Cloud Service Mesh request certs without owning the key.
certificate_requesters = [
"serviceAccount:asm-mesh-ca@kv-platform-prod.iam.gserviceaccount.com",
]
deletion_protection = true
labels = {
environment = "prod"
owner = "platform-eng"
}
}
# Downstream: write the root CA PEM into a Secret Manager secret so workloads
# and the mesh sidecars can mount it as their trust bundle.
resource "google_secret_manager_secret" "internal_root_ca" {
project = "kv-platform-prod"
secret_id = "internal-root-ca-bundle"
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "internal_root_ca" {
secret = google_secret_manager_secret.internal_root_ca.id
secret_data = module.certificate_authority_service.pem_ca_certificate
}
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_authority_service/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-authority-service?ref=v1.0.0"
}
inputs = {
project_id = "..."
location = "..."
name_prefix = "..."
subject = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/certificate_authority_service && 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 | Project hosting the CA pool and CA. |
location |
string |
— | Yes | Region for the CA pool / CA (CA Service is regional). |
name_prefix |
string |
— | Yes | Prefix used to derive pool and CA IDs; validated lowercase/hyphen. |
ca_pool_id |
string |
null |
No | Explicit immutable pool ID; defaults to <name_prefix>-pool. |
ca_id |
string |
null |
No | Explicit immutable CA ID; defaults to <name_prefix>-ca. |
tier |
string |
"ENTERPRISE" |
No | ENTERPRISE (policy + CRL) or DEVOPS (cheaper, no tracking). Immutable. |
ca_type |
string |
"SELF_SIGNED" |
No | SELF_SIGNED root or SUBORDINATE. |
ca_lifetime |
string |
"315360000s" |
No | CA cert lifetime in seconds (default 10 years). |
key_algorithm |
string |
"RSA_PKCS1_4096_SHA256" |
No | HSM signing key algorithm (RSA_* or EC_P256/P384). |
subject |
object |
— | Yes | X.509 subject (organization, common_name required; rest optional). |
max_issuer_path_length |
number |
0 |
No | Max subordinate CAs allowed below this CA. |
max_issued_cert_lifetime |
string |
"7776000s" |
No | Leaf lifetime cap (90 days); null disables policy. ENTERPRISE only. |
allow_client_auth |
bool |
true |
No | Permit clientAuth EKU for mTLS client certs. |
allow_csr_issuance |
bool |
true |
No | Allow issuance from a raw CSR. |
allow_config_issuance |
bool |
true |
No | Allow issuance from structured config (no CSR). |
publish_ca_cert |
bool |
true |
No | Publish CA cert for AIA references. |
publish_crl |
bool |
true |
No | Publish CRL (ENTERPRISE only; ignored on DEVOPS). |
certificate_requesters |
list(string) |
[] |
No | IAM members granted roles/privateca.certificateRequester on the pool. |
deletion_protection |
bool |
true |
No | Block terraform destroy of the CA. |
pending_deletion_grace_period |
string |
"2592000s" |
No | Soft-delete grace period (seconds) before permanent purge. |
skip_grace_period |
bool |
false |
No | Purge immediately on delete (dangerous for roots). |
ignore_active_certificates_on_deletion |
bool |
false |
No | Allow deletion even with unexpired issued certs. |
labels |
map(string) |
{} |
No | Extra labels merged onto pool and CA. |
Outputs
| Name | Description |
|---|---|
ca_pool_id |
Fully-qualified CA pool resource ID. |
ca_pool_name |
Short name of the CA pool. |
certificate_authority_id |
Fully-qualified certificate authority resource ID. |
certificate_authority_name |
Short certificate_authority_id of the CA. |
ca_state |
Current CA state (ENABLED, STAGED, DISABLED, …). |
pem_ca_certificate |
Self-signed PEM cert to distribute to trust stores. |
pem_ca_certificates_chain |
Full PEM chain leaf-to-root. |
tier |
Resolved pool tier. |
Enterprise scenario
A fintech platform team runs Cloud Service Mesh across three GKE clusters and must stop paying a public CA per workload certificate while satisfying an auditor’s requirement that signing keys never leave an HSM. They deploy this module once per region as an ENTERPRISE-tier SELF_SIGNED root with EC_P384_SHA384 keys, a 90-day leaf cap, and max_issuer_path_length = 1, then grant the mesh’s CA service account certificateRequester so sidecars mint short-lived mTLS certs automatically — while the root PEM is pushed to Secret Manager and rolled out as the cluster trust bundle. Because deletion_protection is on and the grace period is 30 days, an accidental terraform destroy cannot silently vaporise the organisation’s entire internal trust anchor.
Best practices
- Pick the tier deliberately and never plan to “upgrade” it —
tieris immutable. UseENTERPRISEfor any pool that needs issuance policy, CRL/revocation, or per-cert tracking; reserveDEVOPSfor throwaway, high-volume, short-lived issuance where you accept no revocation. - Keep roots cheap and quiet: a CA is billed for as long as it exists. Issue from a subordinate, keep the root’s leaf-issuance disabled where possible, and don’t spin up CAs you aren’t using — destroy or leave them
STAGEDrather thanENABLED. - Constrain issuance, not just access: set
max_issued_cert_lifetimeand pin the baselineca_options.is_ca = falseso a compromisedcertificateRequestercan’t mint a long-lived sub-CA. GrantcertificateRequesterto service accounts, nevercaManager/admin. - Always protect the trust anchor: leave
deletion_protection = true, keep a non-zeropending_deletion_grace_period, and never setskip_grace_period = trueon a root in production — recreating a root means re-distributing trust everywhere. - Prefer EC keys for meshes (
EC_P384_SHA384): smaller, faster handshakes for high-churn sidecar traffic, while still HSM-backed; use RSA-4096 only where legacy clients demand it. - Name deterministically and regionally: bake environment/region into
name_prefix(corp-internal,corp-internal-eu) because pool and CA IDs are immutable, and CA Service is regional — plan one pool per region rather than reusing IDs across locations.