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:
- 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_daysexplicit and keeps you deliberate about how many CAs you create. - A ROOT CA is inert until it has a certificate. Creating
aws_acmpca_certificate_authoritywithtype = "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 owncertificate_signing_requestintoaws_acmpca_certificatewith theRootCACertificate/V1template, then install the result withaws_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
- You need internal TLS — mTLS between microservices, private certs on internal ALBs, EKS webhook/admission certs — and a public CA is inappropriate.
- You are building a two-tier PKI: a long-lived offline-ish ROOT plus one or more SUBORDINATE CAs that do the issuing, all codified and version-controlled.
- You run an IoT or device fleet that needs device identity certificates from a private CA you control.
- You want CA creation to be deliberate and auditable — explicit key/signing algorithms, mandatory revocation config, a known deletion window — instead of click-ops that leaves orphaned, billing CAs around.
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 changingcertificate_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 config — live/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 config — live/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
- Build a two-tier hierarchy and never issue from the root. Keep the ROOT as a long-lived trust anchor and do all day-to-day issuing from a SUBORDINATE, so a compromised issuing CA can be revoked and replaced without re-rooting trust across the estate.
- Use a strong key for the root, a lighter one for issuers.
RSA_4096/SHA512WITHRSAfor a 10-year root;RSA_2048/SHA256WITHRSA(or EC) for shorter-lived subordinates. Remember the algorithm and subject are immutable — changing them re-creates the CA. - Always configure revocation. Enable
crl_enabled(with a properly-policied S3 bucket) or an OCSP responder for GENERAL_PURPOSE CAs; revocation is the only way to kill a compromised certificate before it expires. - Mind the hourly bill — don’t sprawl CAs. A CA costs money every hour it exists, regardless of certs issued. Centralize a small number of CAs in a shared-services account and share the issuer via RAM rather than minting one per team.
- Keep a sane deletion window. Leave
permanent_deletion_time_in_daysat 30 in production so an accidental destroy can be restored; only shorten it for genuinely disposable dev CAs. - Use short-lived mode only where it fits.
SHORT_LIVED_CERTIFICATEis great for ≤7-day, CRL-free dev certs issued via the PCA API — but ACM cannot issue from such a CA, so keep production issuing CAs onGENERAL_PURPOSE.