Automation

Terraform, Ansible, and Certificate-as-Code: Embedding TLS Into Your IaC Pipeline

Certificates shouldn't be a manual side-quest in your deployment pipeline. Here's how modern DevOps teams are treating TLS certificates as infrastructure-as-code artifacts - with practical examples for Terraform, Ansible, cert-manager, and GitOps workflows.

F
Fizer Khan
Co-founder & CTO
2026-06-07
13 min read

Certificates Are Infrastructure

If you manage your servers with Terraform, your configurations with Ansible, and your deployments with ArgoCD - but you manage your certificates with emails and spreadsheets - you have a gap in your infrastructure-as-code strategy.

Certificates are infrastructure. They have lifecycle requirements (issuance, renewal, revocation), they're deployed to specific endpoints, they have configuration parameters (algorithm, key size, SANs), and they expire. They belong in your IaC pipeline alongside every other infrastructure component.

The concept of Certificate-as-Code means defining your certificate requirements in declarative configuration, version-controlling those definitions, and letting your automation pipeline handle the rest - just like you do for VMs, networks, and DNS records.

Why Traditional Cert Management Breaks in IaC Workflows

Traditional certificate management assumes a human-driven workflow:

text
1Traditional flow: 2 1. Developer opens a ticket: "I need a certificate for api.example.com" 3 2. Security team reviews and approves (1-3 days) 4 3. Someone generates a CSR manually 5 4. CSR is submitted to the CA portal 6 5. CA validates and issues the certificate (minutes to days) 7 6. Certificate is downloaded and emailed to the developer 8 7. Developer SSH's into the server and installs it 9 8. Developer updates the service configuration 10 9. 12 months later, go back to step 1

This workflow doesn't survive contact with modern infrastructure:

  • Terraform creates and destroys infrastructure in minutes. A certificate process that takes days creates a bottleneck.
  • Kubernetes pods are ephemeral. A certificate installed manually on a pod is lost when the pod restarts.
  • GitOps requires that the desired state is in Git. Certificates managed outside Git are invisible to your GitOps workflow.
  • 47-day lifetimes mean certificates renew 8x more often. A manual process that was tolerable annually becomes unsustainable monthly.

Terraform: Declaring Certificates as Resources

The ACME Provider

Terraform's ACME provider lets you request certificates from any ACME-compliant CA as part of your infrastructure provisioning:

hcl
1# Configure the ACME provider 2provider "acme" { 3 server_url = "https://acme-v02.api.letsencrypt.org/directory" 4} 5 6# Create an account key 7resource "tls_private_key" "acme_account" { 8 algorithm = "ECDSA" 9 ecdsa_curve = "P384" 10} 11 12# Register with the ACME CA 13resource "acme_registration" "reg" { 14 account_key_pem = tls_private_key.acme_account.private_key_pem 15 email_address = "[email protected]" 16} 17 18# Request a certificate 19resource "acme_certificate" "api" { 20 account_key_pem = acme_registration.reg.account_key_pem 21 common_name = "api.example.com" 22 subject_alternative_names = ["api-v2.example.com"] 23 key_type = "P384" 24 25 # Minimum days remaining before Terraform triggers renewal 26 min_days_remaining = 14 27 28 dns_challenge { 29 provider = "route53" 30 config = { 31 AWS_HOSTED_ZONE_ID = var.route53_zone_id 32 } 33 } 34} 35 36# Deploy to AWS ALB 37resource "aws_acm_certificate" "api" { 38 private_key = acme_certificate.api.private_key_pem 39 certificate_body = acme_certificate.api.certificate_pem 40 certificate_chain = acme_certificate.api.issuer_pem 41} 42 43resource "aws_lb_listener" "api_https" { 44 load_balancer_arn = aws_lb.api.arn 45 port = 443 46 protocol = "HTTPS" 47 ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" 48 certificate_arn = aws_acm_certificate.api.arn 49 50 default_action { 51 type = "forward" 52 target_group_arn = aws_lb_target_group.api.arn 53 } 54}

This Terraform configuration:

  1. Creates an ACME account
  2. Requests a certificate with DNS-01 validation via Route 53
  3. Imports the certificate into AWS Certificate Manager
  4. Attaches it to an ALB listener
  5. Automatically renews when fewer than 14 days remain

HashiCorp Vault PKI

For private certificates, Vault's PKI secrets engine integrates naturally with Terraform:

hcl
1# Request a private certificate from Vault PKI 2resource "vault_pki_secret_backend_cert" "internal_api" { 3 backend = "pki-intermediate" 4 name = "internal-services" 5 common_name = "api.internal.example.com" 6 alt_names = ["api-grpc.internal.example.com"] 7 ttl = "720h" # 30 days 8 auto_renew = true 9 min_seconds_remaining = 259200 # 3 days 10 11 # Certificate is stored in Terraform state 12 # Use remote state with encryption! 13} 14 15# Deploy the certificate to a target 16resource "null_resource" "deploy_cert" { 17 triggers = { 18 cert_serial = vault_pki_secret_backend_cert.internal_api.serial_number 19 } 20 21 provisioner "remote-exec" { 22 inline = [ 23 "sudo tee /etc/ssl/certs/api.pem <<< '${vault_pki_secret_backend_cert.internal_api.certificate}'", 24 "sudo systemctl reload nginx" 25 ] 26 } 27}

Cloud Provider Certificates

Each cloud provider offers managed certificate services that work with Terraform:

hcl
1# AWS Certificate Manager (public certificates) 2resource "aws_acm_certificate" "public" { 3 domain_name = "www.example.com" 4 subject_alternative_names = ["example.com"] 5 validation_method = "DNS" 6 7 lifecycle { 8 create_before_destroy = true 9 } 10} 11 12# Azure Key Vault Certificate 13resource "azurerm_key_vault_certificate" "api" { 14 name = "api-cert" 15 key_vault_id = azurerm_key_vault.main.id 16 17 certificate_policy { 18 issuer_parameters { 19 name = "Self" 20 } 21 key_properties { 22 exportable = true 23 key_size = 4096 24 key_type = "RSA" 25 reuse_key = false 26 } 27 x509_certificate_properties { 28 subject = "CN=api.example.com" 29 validity_in_months = 1 30 } 31 } 32} 33 34# GCP Certificate Manager 35resource "google_certificate_manager_certificate" "api" { 36 name = "api-cert" 37 managed { 38 domains = ["api.example.com"] 39 } 40}

Ansible: Certificate Deployment and Rotation

While Terraform excels at provisioning certificates, Ansible is often better suited for deploying certificates to existing infrastructure and handling rotation across fleets of servers.

Certificate Deployment Playbook

yaml
1--- 2# deploy-certificates.yml 3- name: Deploy TLS certificates 4 hosts: webservers 5 become: true 6 vars: 7 cert_dir: /etc/ssl/certs 8 key_dir: /etc/ssl/private 9 service_name: nginx 10 11 tasks: 12 - name: Ensure certificate directories exist 13 file: 14 path: "{{ item }}" 15 state: directory 16 mode: '0755' 17 loop: 18 - "{{ cert_dir }}" 19 - "{{ key_dir }}" 20 21 - name: Deploy certificate 22 copy: 23 content: "{{ lookup('file', 'certs/' + inventory_hostname + '.pem') }}" 24 dest: "{{ cert_dir }}/{{ inventory_hostname }}.pem" 25 mode: '0644' 26 notify: Reload service 27 28 - name: Deploy private key 29 copy: 30 content: "{{ lookup('hashi_vault', 'secret=pki/issue/webserver common_name=' + inventory_hostname) }}" 31 dest: "{{ key_dir }}/{{ inventory_hostname }}.key" 32 mode: '0600' 33 owner: root 34 group: root 35 no_log: true 36 notify: Reload service 37 38 - name: Verify certificate matches key 39 shell: | 40 cert_mod=$(openssl x509 -noout -modulus -in {{ cert_dir }}/{{ inventory_hostname }}.pem | md5sum) 41 key_mod=$(openssl rsa -noout -modulus -in {{ key_dir }}/{{ inventory_hostname }}.key | md5sum) 42 [ "$cert_mod" = "$key_mod" ] 43 changed_when: false 44 45 - name: Verify certificate is not expiring within 7 days 46 shell: | 47 openssl x509 -checkend 604800 -in {{ cert_dir }}/{{ inventory_hostname }}.pem 48 changed_when: false 49 register: cert_check 50 failed_when: cert_check.rc != 0 51 52 handlers: 53 - name: Reload service 54 service: 55 name: "{{ service_name }}" 56 state: reloaded

Certificate Rotation Playbook

yaml
1--- 2# rotate-certificates.yml 3- name: Rotate expiring certificates 4 hosts: all 5 become: true 6 serial: "25%" # Rolling deployment - 25% of hosts at a time 7 8 tasks: 9 - name: Check certificate expiry 10 shell: | 11 openssl x509 -enddate -noout -in /etc/ssl/certs/{{ inventory_hostname }}.pem 12 register: cert_expiry 13 changed_when: false 14 15 - name: Request new certificate if expiring within 14 days 16 uri: 17 url: "https://clm.example.com/api/v1/certificates/renew" 18 method: POST 19 headers: 20 Authorization: "Bearer {{ clm_api_token }}" 21 body_format: json 22 body: 23 common_name: "{{ inventory_hostname }}" 24 key_type: "ECDSA" 25 key_curve: "P-384" 26 return_content: true 27 register: new_cert 28 when: cert_expiry.stdout | regex_search('notAfter=(.*)') | to_datetime('%b %d %H:%M:%S %Y %Z') < (ansible_date_time.iso8601 | to_datetime('%Y-%m-%dT%H:%M:%SZ')) + timedelta(days=14) 29 30 - name: Deploy new certificate 31 copy: 32 content: "{{ new_cert.json.certificate }}" 33 dest: "/etc/ssl/certs/{{ inventory_hostname }}.pem" 34 mode: '0644' 35 when: new_cert is changed 36 notify: Reload nginx 37 38 - name: Verify new certificate is serving 39 uri: 40 url: "https://{{ inventory_hostname }}/" 41 validate_certs: true 42 delegate_to: localhost 43 when: new_cert is changed 44 45 handlers: 46 - name: Reload nginx 47 service: 48 name: nginx 49 state: reloaded

cert-manager: The Kubernetes-Native Approach

For Kubernetes environments, cert-manager is the standard for certificate-as-code. It treats certificates as native Kubernetes resources that are automatically provisioned, renewed, and deployed.

yaml
1# ClusterIssuer - defines where certificates come from 2apiVersion: cert-manager.io/v1 3kind: ClusterIssuer 4metadata: 5 name: letsencrypt-prod 6spec: 7 acme: 8 server: https://acme-v02.api.letsencrypt.org/directory 9 email: [email protected] 10 privateKeySecretRef: 11 name: letsencrypt-account-key 12 solvers: 13 - http01: 14 ingress: 15 class: nginx 16 17--- 18# Certificate - declares what certificate you need 19apiVersion: cert-manager.io/v1 20kind: Certificate 21metadata: 22 name: api-tls 23 namespace: production 24spec: 25 secretName: api-tls-secret 26 duration: 720h # 30 days 27 renewBefore: 168h # Renew 7 days before expiry 28 issuerRef: 29 name: letsencrypt-prod 30 kind: ClusterIssuer 31 dnsNames: 32 - api.example.com 33 - api-v2.example.com 34 privateKey: 35 algorithm: ECDSA 36 size: 384 37 38--- 39# Ingress - automatically uses the certificate 40apiVersion: networking.k8s.io/v1 41kind: Ingress 42metadata: 43 name: api-ingress 44 annotations: 45 cert-manager.io/cluster-issuer: letsencrypt-prod 46spec: 47 tls: 48 - hosts: 49 - api.example.com 50 secretName: api-tls-secret 51 rules: 52 - host: api.example.com 53 http: 54 paths: 55 - path: / 56 pathType: Prefix 57 backend: 58 service: 59 name: api-service 60 port: 61 number: 8080

With cert-manager:

  • Certificates are declared in YAML and version-controlled in Git
  • Renewal happens automatically before expiry
  • New certificates are stored as Kubernetes secrets
  • Ingress controllers pick up new certificates without restarts
  • Everything is visible via kubectl get certificates

GitOps Certificate Rotation Patterns

ArgoCD with cert-manager

In a GitOps workflow with ArgoCD, certificate declarations live in your Git repository:

text
1repo/ 2├── apps/ 3│ └── api/ 4│ ├── deployment.yaml 5│ ├── service.yaml 6│ ├── ingress.yaml 7│ └── certificate.yaml ← Certificate definition in Git 8├── infrastructure/ 9│ └── cert-manager/ 10│ ├── cluster-issuer.yaml ← CA configuration in Git 11│ └── certificate-policies.yaml 12└── argocd/ 13 └── applications.yaml

ArgoCD syncs the certificate definitions to the cluster. cert-manager handles the actual issuance and renewal. The Git repository contains the intent ("I need a certificate for api.example.com"), not the certificate itself.

Flux with External Secrets

For organizations using Flux, certificates can be managed through the External Secrets Operator:

yaml
1apiVersion: external-secrets.io/v1beta1 2kind: ExternalSecret 3metadata: 4 name: api-tls-external 5 namespace: production 6spec: 7 refreshInterval: 1h 8 secretStoreRef: 9 name: vault-backend 10 kind: ClusterSecretStore 11 target: 12 name: api-tls-secret 13 template: 14 type: kubernetes.io/tls 15 data: 16 - secretKey: tls.crt 17 remoteRef: 18 key: pki/issue/webserver 19 property: certificate 20 - secretKey: tls.key 21 remoteRef: 22 key: pki/issue/webserver 23 property: private_key

Security Considerations: Private Keys and State

The State File Problem

Terraform stores resource state, including certificate private keys, in its state file. This is a significant security concern:

text
1⚠️ Terraform state contains private keys in plaintext 2 3Do: 4 ✓ Use remote state backends with encryption (S3 + KMS, Azure Blob + CMK) 5 ✓ Enable state file encryption at rest 6 ✓ Restrict state file access with IAM policies 7 ✓ Use separate state files for certificate resources 8 ✓ Enable state file versioning for audit trails 9 10Don't: 11 ✗ Store state files in Git 12 ✗ Use local state files for production certificates 13 ✗ Share state file access broadly 14 ✗ Log state file contents in CI/CD output

Keeping Keys Off Disk

The most secure pattern avoids storing private keys in Terraform state entirely:

hcl
1# Generate the key in Vault - never touches Terraform state 2resource "vault_pki_secret_backend_cert" "api" { 3 backend = "pki" 4 name = "webserver" 5 common_name = "api.example.com" 6} 7 8# Only reference the Vault path, not the key material 9output "cert_vault_path" { 10 value = vault_pki_secret_backend_cert.api.backend 11}

CI/CD Pipeline Security

When certificates are issued in CI/CD pipelines:

yaml
1# GitHub Actions - secure certificate deployment 2- name: Request certificate 3 env: 4 CLM_API_TOKEN: ${{ secrets.CLM_API_TOKEN }} 5 run: | 6 # Request cert via API - private key never written to disk 7 curl -s -H "Authorization: Bearer $CLM_API_TOKEN" https://clm.example.com/api/v1/certificates/issue -d '{"cn": "api.example.com", "key_type": "ECDSA"}' | jq -r '.certificate' > /tmp/cert.pem 8 9 # Deploy directly to target - cert is ephemeral 10 kubectl create secret tls api-tls --cert=/tmp/cert.pem --key=<(curl -s -H "Authorization: Bearer $CLM_API_TOKEN" https://clm.example.com/api/v1/certificates/key) 11 12 # Clean up 13 rm -f /tmp/cert.pem

How TigerTrust Integrates with IaC Pipelines

TigerTrust is designed to be a native component of your infrastructure-as-code workflow:

  • Terraform provider: A native Terraform provider for requesting and managing certificates through TigerTrust's API - certificates as Terraform resources with full lifecycle management
  • Ansible collection: Pre-built Ansible roles for certificate deployment, rotation, and validation across your server fleet
  • cert-manager integration: ClusterIssuer configuration that connects cert-manager to TigerTrust's ACME server for Kubernetes-native certificate management
  • CI/CD plugins: GitHub Actions, GitLab CI, and Jenkins plugins for certificate operations in your deployment pipeline
  • API-first design: Every TigerTrust operation is available via REST API, so any IaC tool can integrate
  • State-free architecture: TigerTrust manages certificate state centrally, so your Terraform state doesn't need to contain private keys

Certificates are infrastructure. Manage them like infrastructure. See how TigerTrust fits into your IaC pipeline at tigertrust.io.

TOPICS

Terraform
Ansible
certificate-as-code
IaC
GitOps
cert-manager
DevOps
TigerTrust

SHARE THIS ARTICLE

Ready to Transform Your Certificate Management?

See how TigerTrust can help you automate certificate lifecycle management at scale.