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.

J
James Chen
Security Researcher
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:

Traditional flow:
  1. Developer opens a ticket: "I need a certificate for api.example.com"
  2. Security team reviews and approves (1-3 days)
  3. Someone generates a CSR manually
  4. CSR is submitted to the CA portal
  5. CA validates and issues the certificate (minutes to days)
  6. Certificate is downloaded and emailed to the developer
  7. Developer SSH's into the server and installs it
  8. Developer updates the service configuration
  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:

# Configure the ACME provider provider "acme" { server_url = "https://acme-v02.api.letsencrypt.org/directory" } # Create an account key resource "tls_private_key" "acme_account" { algorithm = "ECDSA" ecdsa_curve = "P384" } # Register with the ACME CA resource "acme_registration" "reg" { account_key_pem = tls_private_key.acme_account.private_key_pem email_address = "[email protected]" } # Request a certificate resource "acme_certificate" "api" { account_key_pem = acme_registration.reg.account_key_pem common_name = "api.example.com" subject_alternative_names = ["api-v2.example.com"] key_type = "P384" # Minimum days remaining before Terraform triggers renewal min_days_remaining = 14 dns_challenge { provider = "route53" config = { AWS_HOSTED_ZONE_ID = var.route53_zone_id } } } # Deploy to AWS ALB resource "aws_acm_certificate" "api" { private_key = acme_certificate.api.private_key_pem certificate_body = acme_certificate.api.certificate_pem certificate_chain = acme_certificate.api.issuer_pem } resource "aws_lb_listener" "api_https" { load_balancer_arn = aws_lb.api.arn port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = aws_acm_certificate.api.arn default_action { type = "forward" target_group_arn = aws_lb_target_group.api.arn } }

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:

# Request a private certificate from Vault PKI resource "vault_pki_secret_backend_cert" "internal_api" { backend = "pki-intermediate" name = "internal-services" common_name = "api.internal.example.com" alt_names = ["api-grpc.internal.example.com"] ttl = "720h" # 30 days auto_renew = true min_seconds_remaining = 259200 # 3 days # Certificate is stored in Terraform state # Use remote state with encryption! } # Deploy the certificate to a target resource "null_resource" "deploy_cert" { triggers = { cert_serial = vault_pki_secret_backend_cert.internal_api.serial_number } provisioner "remote-exec" { inline = [ "sudo tee /etc/ssl/certs/api.pem <<< '${vault_pki_secret_backend_cert.internal_api.certificate}'", "sudo systemctl reload nginx" ] } }

Cloud Provider Certificates

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

# AWS Certificate Manager (public certificates) resource "aws_acm_certificate" "public" { domain_name = "www.example.com" subject_alternative_names = ["example.com"] validation_method = "DNS" lifecycle { create_before_destroy = true } } # Azure Key Vault Certificate resource "azurerm_key_vault_certificate" "api" { name = "api-cert" key_vault_id = azurerm_key_vault.main.id certificate_policy { issuer_parameters { name = "Self" } key_properties { exportable = true key_size = 4096 key_type = "RSA" reuse_key = false } x509_certificate_properties { subject = "CN=api.example.com" validity_in_months = 1 } } } # GCP Certificate Manager resource "google_certificate_manager_certificate" "api" { name = "api-cert" managed { domains = ["api.example.com"] } }

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

--- # deploy-certificates.yml - name: Deploy TLS certificates hosts: webservers become: true vars: cert_dir: /etc/ssl/certs key_dir: /etc/ssl/private service_name: nginx tasks: - name: Ensure certificate directories exist file: path: "{{ item }}" state: directory mode: '0755' loop: - "{{ cert_dir }}" - "{{ key_dir }}" - name: Deploy certificate copy: content: "{{ lookup('file', 'certs/' + inventory_hostname + '.pem') }}" dest: "{{ cert_dir }}/{{ inventory_hostname }}.pem" mode: '0644' notify: Reload service - name: Deploy private key copy: content: "{{ lookup('hashi_vault', 'secret=pki/issue/webserver common_name=' + inventory_hostname) }}" dest: "{{ key_dir }}/{{ inventory_hostname }}.key" mode: '0600' owner: root group: root no_log: true notify: Reload service - name: Verify certificate matches key shell: | cert_mod=$(openssl x509 -noout -modulus -in {{ cert_dir }}/{{ inventory_hostname }}.pem | md5sum) key_mod=$(openssl rsa -noout -modulus -in {{ key_dir }}/{{ inventory_hostname }}.key | md5sum) [ "$cert_mod" = "$key_mod" ] changed_when: false - name: Verify certificate is not expiring within 7 days shell: | openssl x509 -checkend 604800 -in {{ cert_dir }}/{{ inventory_hostname }}.pem changed_when: false register: cert_check failed_when: cert_check.rc != 0 handlers: - name: Reload service service: name: "{{ service_name }}" state: reloaded

Certificate Rotation Playbook

--- # rotate-certificates.yml - name: Rotate expiring certificates hosts: all become: true serial: "25%" # Rolling deployment - 25% of hosts at a time tasks: - name: Check certificate expiry shell: | openssl x509 -enddate -noout -in /etc/ssl/certs/{{ inventory_hostname }}.pem register: cert_expiry changed_when: false - name: Request new certificate if expiring within 14 days uri: url: "https://clm.example.com/api/v1/certificates/renew" method: POST headers: Authorization: "Bearer {{ clm_api_token }}" body_format: json body: common_name: "{{ inventory_hostname }}" key_type: "ECDSA" key_curve: "P-384" return_content: true register: new_cert 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) - name: Deploy new certificate copy: content: "{{ new_cert.json.certificate }}" dest: "/etc/ssl/certs/{{ inventory_hostname }}.pem" mode: '0644' when: new_cert is changed notify: Reload nginx - name: Verify new certificate is serving uri: url: "https://{{ inventory_hostname }}/" validate_certs: true delegate_to: localhost when: new_cert is changed handlers: - name: Reload nginx service: name: nginx 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.

# ClusterIssuer - defines where certificates come from apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: [email protected] privateKeySecretRef: name: letsencrypt-account-key solvers: - http01: ingress: class: nginx --- # Certificate - declares what certificate you need apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: api-tls namespace: production spec: secretName: api-tls-secret duration: 720h # 30 days renewBefore: 168h # Renew 7 days before expiry issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - api.example.com - api-v2.example.com privateKey: algorithm: ECDSA size: 384 --- # Ingress - automatically uses the certificate apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: api-ingress annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: tls: - hosts: - api.example.com secretName: api-tls-secret rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: api-service port: 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:

repo/
├── apps/
│   └── api/
│       ├── deployment.yaml
│       ├── service.yaml
│       ├── ingress.yaml
│       └── certificate.yaml    ← Certificate definition in Git
├── infrastructure/
│   └── cert-manager/
│       ├── cluster-issuer.yaml ← CA configuration in Git
│       └── certificate-policies.yaml
└── argocd/
    └── 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:

apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: name: api-tls-external namespace: production spec: refreshInterval: 1h secretStoreRef: name: vault-backend kind: ClusterSecretStore target: name: api-tls-secret template: type: kubernetes.io/tls data: - secretKey: tls.crt remoteRef: key: pki/issue/webserver property: certificate - secretKey: tls.key remoteRef: key: pki/issue/webserver 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:

⚠️  Terraform state contains private keys in plaintext

Do:
  ✓ Use remote state backends with encryption (S3 + KMS, Azure Blob + CMK)
  ✓ Enable state file encryption at rest
  ✓ Restrict state file access with IAM policies
  ✓ Use separate state files for certificate resources
  ✓ Enable state file versioning for audit trails

Don't:
  ✗ Store state files in Git
  ✗ Use local state files for production certificates
  ✗ Share state file access broadly
  ✗ Log state file contents in CI/CD output

Keeping Keys Off Disk

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

# Generate the key in Vault — never touches Terraform state resource "vault_pki_secret_backend_cert" "api" { backend = "pki" name = "webserver" common_name = "api.example.com" } # Only reference the Vault path, not the key material output "cert_vault_path" { value = vault_pki_secret_backend_cert.api.backend }

CI/CD Pipeline Security

When certificates are issued in CI/CD pipelines:

# GitHub Actions - secure certificate deployment - name: Request certificate env: CLM_API_TOKEN: ${{ secrets.CLM_API_TOKEN }} run: | # Request cert via API - private key never written to disk 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 # Deploy directly to target - cert is ephemeral 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) # Clean up 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.