IaC GCP

Terraform Module: GCP Certificate Authority Service — a governed private CA pool in one call

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

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

TerraformGCPCertificate Authority ServiceModuleIaC
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