IaC AWS

Terraform Module: AWS Private CA (ACM PCA) — A Root or Subordinate Certificate Authority as Code

Quick take — A reusable hashicorp/aws ~> 5.0 module for AWS Private Certificate Authority: ROOT or SUBORDINATE CAs, key/signing algorithm config, CRL or OCSP revocation, GENERAL_PURPOSE or short-lived usage mode, and optional self-signed ROOT activation. 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 "aws" {
  region = "us-east-1"
}

module "private_ca" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm-pca?ref=v1.0.0"

  type        = "ROOT"           # ROOT (self-signed) or SUBORDINATE (signed by another CA).
  common_name = "..."            # CA subject CN, e.g. "Example Corp Root CA".

  subject = {
    organization = "..."         # Legal org name in the certificate subject.
    country      = "..."         # ISO 3166 two-letter country code, e.g. "US".
  }

  # Self-sign and activate the ROOT in the same apply (ROOT only).
  activate_root = true

  # ...subordinate CAs instead consume `certificate_signing_request` from the
  # CA below and a parent CA signs it — see "How to use it".
}

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

What this module is

AWS Private Certificate Authority (ACM PCA) is a managed PKI service that lets you stand up your own internal certificate authority — issuing private TLS certificates for service-to-service mTLS, internal APIs, IoT fleets, and anything where a public CA is wrong. It removes the operational pain of running OpenSSL on a locked-down host, but it carries two sharp edges that catch people out:

  1. A CA bills hourly from the moment it exists until it is deleted (and deletion has a mandatory restore window). An idle CA is not free — so this module makes permanent_deletion_time_in_days explicit and keeps you deliberate about how many CAs you create.
  2. A ROOT CA is inert until it has a certificate. Creating aws_acmpca_certificate_authority with type = "ROOT" gives you a CA that is pending — it has generated a key pair and a CSR but cannot sign anything. You activate it by self-signing: feed its own certificate_signing_request into aws_acmpca_certificate with the RootCACertificate/V1 template, then install the result with aws_acmpca_certificate_authority_certificate.

The core resource, aws_acmpca_certificate_authority, takes a certificate_authority_configuration block (key_algorithm like RSA_2048, signing_algorithm like SHA256WITHRSA, and a subject with common_name, organization, country, …), a revocation_configuration (a crl_configuration publishing CRLs to an S3 bucket, or an ocsp_configuration), a usage_mode (GENERAL_PURPOSE for normal certs, SHORT_LIVED_CERTIFICATE for ≤7-day certs that skip CRLs), and a key_storage_security_standard. This module wraps all of that, then optionally drives the self-sign-and-activate dance for ROOT CAs so a single apply leaves you with a usable authority.

ROOT vs SUBORDINATE is the design decision: a ROOT is the trust anchor — self-signed, long-lived (10+ years), and you should never issue end-entity certs from it directly. A SUBORDINATE is signed by a parent (your root, or an external CA) and is what actually issues day-to-day certificates, so a compromise is contained and rotatable. This module builds either, and for subordinates it exposes the CSR you hand to the signing parent.

When to use it

Reach for short-lived mode (SHORT_LIVED_CERTIFICATE) only for dev/test workflows that issue ≤7-day certs directly via the PCA API (note: ACM itself cannot issue from a short-lived CA). For end-entity issuance and renewal, layer aws_acm_certificate with certificate_authority_arn on top of this module’s subordinate CA — this module owns the authority, not the leaf certs it eventually signs.

Module structure

terraform-module-aws-acm-pca/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # CA, optional self-signed root cert + activation
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # CA ARN, CSR, certificate, serial

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      "Name"      = var.common_name
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-acm-pca"
    },
    var.tags,
  )

  # Self-signing the root only makes sense for a ROOT CA in GENERAL_PURPOSE
  # mode. Subordinates are signed by a parent CA outside this module.
  do_activate_root = var.activate_root && var.type == "ROOT"
}

resource "aws_acmpca_certificate_authority" "this" {
  type       = var.type
  usage_mode = var.usage_mode

  # Where the CA private key lives. FIPS_140_2_LEVEL_3_OR_HIGHER is the default
  # in most regions; some regions only support LEVEL_2.
  key_storage_security_standard = var.key_storage_security_standard

  certificate_authority_configuration {
    key_algorithm     = var.key_algorithm
    signing_algorithm = var.signing_algorithm

    subject {
      common_name         = var.common_name
      organization        = var.subject.organization
      organizational_unit = var.subject.organizational_unit
      country             = var.subject.country
      state               = var.subject.state
      locality            = var.subject.locality
    }
  }

  # Revocation: a CRL published to S3, an OCSP responder, or neither (e.g.
  # short-lived mode). Exactly one block is rendered based on the variables.
  dynamic "revocation_configuration" {
    for_each = (var.crl_enabled || var.ocsp_enabled) ? [1] : []
    content {
      dynamic "crl_configuration" {
        for_each = var.crl_enabled ? [1] : []
        content {
          enabled            = true
          expiration_in_days = var.crl_expiration_in_days
          s3_bucket_name     = var.crl_s3_bucket_name
          s3_object_acl      = var.crl_s3_object_acl
        }
      }

      dynamic "ocsp_configuration" {
        for_each = var.ocsp_enabled ? [1] : []
        content {
          enabled           = true
          ocsp_custom_cname = var.ocsp_custom_cname
        }
      }
    }
  }

  # How long the CA stays restorable after a delete before it is gone forever.
  permanent_deletion_time_in_days = var.permanent_deletion_time_in_days

  tags = local.tags
}

# ---- ROOT activation: self-sign the CA's own CSR ----
# A ROOT CA is "pending" until it has a certificate. We sign its CSR with the
# RootCACertificate template and the CA's own ARN, then install the result.
resource "aws_acmpca_certificate" "root" {
  count = local.do_activate_root ? 1 : 0

  certificate_authority_arn   = aws_acmpca_certificate_authority.this.arn
  certificate_signing_request = aws_acmpca_certificate_authority.this.certificate_signing_request
  signing_algorithm           = var.signing_algorithm

  template_arn = "arn:aws:acm-pca:::template/RootCACertificate/V1"

  validity {
    type  = var.root_validity_type
    value = var.root_validity_value
  }
}

resource "aws_acmpca_certificate_authority_certificate" "root" {
  count = local.do_activate_root ? 1 : 0

  certificate_authority_arn = aws_acmpca_certificate_authority.this.arn
  certificate               = aws_acmpca_certificate.root[0].certificate
  certificate_chain         = aws_acmpca_certificate.root[0].certificate_chain
}

variables.tf

variable "type" {
  description = "CA type: ROOT (self-signed trust anchor) or SUBORDINATE (signed by a parent CA)."
  type        = string
  default     = "SUBORDINATE"

  validation {
    condition     = contains(["ROOT", "SUBORDINATE"], var.type)
    error_message = "type must be ROOT or SUBORDINATE."
  }
}

variable "usage_mode" {
  description = "GENERAL_PURPOSE for normal certificates, or SHORT_LIVED_CERTIFICATE for <=7-day certs (no CRL)."
  type        = string
  default     = "GENERAL_PURPOSE"

  validation {
    condition     = contains(["GENERAL_PURPOSE", "SHORT_LIVED_CERTIFICATE"], var.usage_mode)
    error_message = "usage_mode must be GENERAL_PURPOSE or SHORT_LIVED_CERTIFICATE."
  }
}

variable "key_algorithm" {
  description = "Key algorithm for the CA private key."
  type        = string
  default     = "RSA_2048"

  validation {
    condition     = contains(["RSA_2048", "RSA_4096", "EC_prime256v1", "EC_secp384r1"], var.key_algorithm)
    error_message = "key_algorithm must be RSA_2048, RSA_4096, EC_prime256v1, or EC_secp384r1."
  }
}

variable "signing_algorithm" {
  description = "Signing algorithm the CA uses (must match the key family)."
  type        = string
  default     = "SHA256WITHRSA"

  validation {
    condition = contains([
      "SHA256WITHRSA", "SHA384WITHRSA", "SHA512WITHRSA",
      "SHA256WITHECDSA", "SHA384WITHECDSA", "SHA512WITHECDSA",
    ], var.signing_algorithm)
    error_message = "signing_algorithm must be a supported SHA*WITHRSA or SHA*WITHECDSA value."
  }
}

variable "common_name" {
  description = "Common name (CN) for the CA certificate subject, e.g. 'Example Corp Root CA'."
  type        = string

  validation {
    condition     = length(var.common_name) > 0 && length(var.common_name) <= 64
    error_message = "common_name must be 1-64 characters."
  }
}

variable "subject" {
  description = <<-EOT
    Additional X.500 subject fields for the CA certificate:
      organization        - legal organization name (required)
      country             - ISO 3166 two-letter country code, e.g. "US" (required)
      organizational_unit - optional OU
      state               - optional state/province
      locality            - optional city
  EOT
  type = object({
    organization        = string
    country             = string
    organizational_unit = optional(string)
    state               = optional(string)
    locality            = optional(string)
  })

  validation {
    condition     = can(regex("^[A-Z]{2}$", var.subject.country))
    error_message = "subject.country must be a two-letter uppercase ISO 3166 country code."
  }
}

variable "crl_enabled" {
  description = "Publish a Certificate Revocation List to S3. Recommended for GENERAL_PURPOSE CAs."
  type        = bool
  default     = true
}

variable "crl_s3_bucket_name" {
  description = "Existing S3 bucket (with a PCA-trust bucket policy) to publish CRLs to. Required when crl_enabled."
  type        = string
  default     = null

  validation {
    condition     = !var.crl_enabled || var.crl_s3_bucket_name != null
    error_message = "crl_s3_bucket_name is required when crl_enabled is true."
  }
}

variable "crl_expiration_in_days" {
  description = "Number of days a published CRL is valid before a new one must be generated."
  type        = number
  default     = 7

  validation {
    condition     = var.crl_expiration_in_days >= 1 && var.crl_expiration_in_days <= 5000
    error_message = "crl_expiration_in_days must be between 1 and 5000."
  }
}

variable "crl_s3_object_acl" {
  description = "ACL applied to CRL objects: PUBLIC_READ or BUCKET_OWNER_FULL_CONTROL."
  type        = string
  default     = "BUCKET_OWNER_FULL_CONTROL"

  validation {
    condition     = contains(["PUBLIC_READ", "BUCKET_OWNER_FULL_CONTROL"], var.crl_s3_object_acl)
    error_message = "crl_s3_object_acl must be PUBLIC_READ or BUCKET_OWNER_FULL_CONTROL."
  }
}

variable "ocsp_enabled" {
  description = "Enable an OCSP responder instead of (or alongside) a CRL."
  type        = bool
  default     = false
}

variable "ocsp_custom_cname" {
  description = "Optional custom CNAME for the OCSP responder endpoint."
  type        = string
  default     = null
}

variable "key_storage_security_standard" {
  description = "FIPS standard for CA key storage. Use FIPS_140_2_LEVEL_2_OR_HIGHER in regions without Level 3."
  type        = string
  default     = "FIPS_140_2_LEVEL_3_OR_HIGHER"

  validation {
    condition = contains(
      ["FIPS_140_2_LEVEL_2_OR_HIGHER", "FIPS_140_2_LEVEL_3_OR_HIGHER"],
      var.key_storage_security_standard
    )
    error_message = "key_storage_security_standard must be FIPS_140_2_LEVEL_2_OR_HIGHER or FIPS_140_2_LEVEL_3_OR_HIGHER."
  }
}

variable "activate_root" {
  description = "For ROOT CAs only: self-sign and install the root certificate in the same apply."
  type        = bool
  default     = false
}

variable "root_validity_type" {
  description = "Validity unit for the self-signed root certificate: YEARS, MONTHS, DAYS, or ABSOLUTE/END_DATE."
  type        = string
  default     = "YEARS"

  validation {
    condition     = contains(["YEARS", "MONTHS", "DAYS", "ABSOLUTE", "END_DATE"], var.root_validity_type)
    error_message = "root_validity_type must be YEARS, MONTHS, DAYS, ABSOLUTE, or END_DATE."
  }
}

variable "root_validity_value" {
  description = "Validity value for the self-signed root certificate (e.g. 10 with YEARS)."
  type        = number
  default     = 10
}

variable "permanent_deletion_time_in_days" {
  description = "Restore window (7-30 days) before a deleted CA is permanently destroyed."
  type        = number
  default     = 30

  validation {
    condition     = var.permanent_deletion_time_in_days >= 7 && var.permanent_deletion_time_in_days <= 30
    error_message = "permanent_deletion_time_in_days must be between 7 and 30."
  }
}

variable "tags" {
  description = "Additional tags merged onto the CA."
  type        = map(string)
  default     = {}
}

outputs.tf

output "arn" {
  description = "ARN of the certificate authority — pass to aws_acm_certificate.certificate_authority_arn."
  value       = aws_acmpca_certificate_authority.this.arn
}

output "id" {
  description = "ID of the certificate authority."
  value       = aws_acmpca_certificate_authority.this.id
}

output "certificate_signing_request" {
  description = "PEM CSR for the CA. For a SUBORDINATE, hand this to the parent CA to sign."
  value       = aws_acmpca_certificate_authority.this.certificate_signing_request
}

output "certificate" {
  description = "The CA's own certificate once installed (ROOT activation), else null."
  value       = try(aws_acmpca_certificate.root[0].certificate, null)
}

output "certificate_chain" {
  description = "The CA certificate chain after activation, or null."
  value       = try(aws_acmpca_certificate.root[0].certificate_chain, null)
}

output "serial" {
  description = "Serial number of the certificate authority."
  value       = aws_acmpca_certificate_authority.this.serial
}

output "not_after" {
  description = "Expiry timestamp of the installed CA certificate, if activated."
  value       = aws_acmpca_certificate_authority.this.not_after
}

How to use it

A typical two-tier PKI: a self-signed ROOT, then a SUBORDINATE signed by it. Notice the subordinate’s CSR is signed with the root CA’s ARN and the SubordinateCACertificate_PathLen0 template.

# 1) Root CA — self-signed and activated in one apply.
module "root_ca" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm-pca?ref=v1.0.0"

  type              = "ROOT"
  key_algorithm     = "RSA_4096"
  signing_algorithm = "SHA512WITHRSA"
  common_name       = "Example Corp Root CA"

  subject = {
    organization = "Example Corp"
    country      = "US"
    state        = "California"
  }

  crl_enabled        = true
  crl_s3_bucket_name = aws_s3_bucket.crl.id

  activate_root       = true
  root_validity_type  = "YEARS"
  root_validity_value = 10

  tags = { Tier = "root", Environment = "prod" }
}

# 2) Subordinate CA — created pending, then signed by the root.
module "issuing_ca" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-acm-pca?ref=v1.0.0"

  type              = "SUBORDINATE"
  key_algorithm     = "RSA_2048"
  signing_algorithm = "SHA256WITHRSA"
  common_name       = "Example Corp Issuing CA"

  subject = {
    organization = "Example Corp"
    country      = "US"
  }

  crl_enabled        = true
  crl_s3_bucket_name = aws_s3_bucket.crl.id

  tags = { Tier = "issuing", Environment = "prod" }
}

# 3) Root signs the subordinate's CSR, then we install the chain on the sub.
resource "aws_acmpca_certificate" "subordinate" {
  certificate_authority_arn   = module.root_ca.arn
  certificate_signing_request = module.issuing_ca.certificate_signing_request
  signing_algorithm           = "SHA512WITHRSA"
  template_arn                = "arn:aws:acm-pca:::template/SubordinateCACertificate_PathLen0/V1"

  validity {
    type  = "YEARS"
    value = 5
  }
}

resource "aws_acmpca_certificate_authority_certificate" "subordinate" {
  certificate_authority_arn = module.issuing_ca.arn
  certificate               = aws_acmpca_certificate.subordinate.certificate
  certificate_chain         = aws_acmpca_certificate.subordinate.certificate_chain
}

# 4) Downstream: issue an end-entity cert from the subordinate via ACM.
resource "aws_acm_certificate" "internal_api" {
  domain_name               = "api.internal.example.com"
  certificate_authority_arn = module.issuing_ca.arn

  lifecycle {
    create_before_destroy = true
  }
}

Pin the module with ?ref=<tag> — and remember that changing certificate_authority_configuration (key/signing algorithm or subject) forces a new CA. Treat those inputs as immutable for a live authority.

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 = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/pca-issuing/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  type = "SUBORDINATE"
  common_name = "..."
  subject = {
    organization = "..."
    country = "..."
  }
  crl_enabled = true
  crl_s3_bucket_name = "..."
}

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

cd live/prod/pca-issuing && 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
type string SUBORDINATE No ROOT or SUBORDINATE.
usage_mode string GENERAL_PURPOSE No GENERAL_PURPOSE or SHORT_LIVED_CERTIFICATE.
key_algorithm string RSA_2048 No CA key algorithm (RSA/EC).
signing_algorithm string SHA256WITHRSA No Signing algorithm matching the key family.
common_name string Yes CA subject common name.
subject object Yes Subject fields (organization, country, optional OU/state/locality).
crl_enabled bool true No Publish a CRL to S3.
crl_s3_bucket_name string null No* S3 bucket for CRLs; required when crl_enabled.
crl_expiration_in_days number 7 No CRL validity in days (1–5000).
crl_s3_object_acl string BUCKET_OWNER_FULL_CONTROL No CRL object ACL.
ocsp_enabled bool false No Enable an OCSP responder.
ocsp_custom_cname string null No Custom CNAME for the OCSP responder.
key_storage_security_standard string FIPS_140_2_LEVEL_3_OR_HIGHER No FIPS key-storage standard.
activate_root bool false No ROOT only: self-sign and install in one apply.
root_validity_type string YEARS No Root cert validity unit.
root_validity_value number 10 No Root cert validity value.
permanent_deletion_time_in_days number 30 No Restore window before permanent deletion (7–30).
tags map(string) {} No Additional tags merged onto the CA.

Outputs

Name Description
arn CA ARN — pass to aws_acm_certificate.certificate_authority_arn.
id CA ID.
certificate_signing_request PEM CSR; for a SUBORDINATE, hand to the parent CA to sign.
certificate The CA’s own certificate after ROOT activation, else null.
certificate_chain CA certificate chain after activation, or null.
serial CA serial number.
not_after Expiry of the installed CA certificate, if activated.

Enterprise scenario

A healthcare SaaS needs internal mTLS across hundreds of microservices and an auditable PKI for compliance. The security team runs this module twice in a dedicated shared-services account: once for a RSA_4096 / SHA512WITHRSA ROOT CA valid for 10 years (self-signed via activate_root, CRLs published to a locked-down S3 bucket), and once for an RSA_2048 SUBORDINATE issuing CA signed by that root and valid for 5 years. The subordinate is shared to every workload account with AWS RAM, so application teams issue end-entity certs through ACM (aws_acm_certificate with the shared certificate_authority_arn) and ACM auto-renews them — no team ever touches the root. Because both CAs bill hourly, the team keeps the count to exactly two, tags them Tier=root|issuing, and sets a 30-day deletion window so a fat-fingered terraform destroy is recoverable. The whole hierarchy is one reviewed repo, and the CRL bucket plus CloudTrail give the auditor a complete issuance and revocation trail.

Best practices

TerraformAWSACM PCAModuleIaC
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