Introduction

When Ansible playbooks automate TLS certificate renewal (via Let's Encrypt, ACME, or internal PKI), the renewed certificate chain file may be incomplete. It contains the server certificate but is missing intermediate CA certificates required for clients to verify the full chain of trust. This causes TLS handshake failures in browsers, API clients, and other services even though the certificate itself is valid.

The issue occurs when the certificate renewal task overwrites the chain file with only the new server certificate, when the CA changes intermediate certificates during renewal, or when the chain file format doesn't concatenate certificates in the correct order.

Symptoms

SSL/TLS handshake failures after certificate renewal:

bash
TASK [Test HTTPS endpoint] *****************************************************
fatal: [load-balancer-01]: FAILED! => {
    "msg": "Failed to validate the SSL certificate for api.example.com:443.
     The certificate issuer 'CN=R3,O=Let's Encrypt,C=US' is unknown.
     Make sure your managed hosts trust the certificate authority."
}

OpenSSL verification fails for the chain:

bash
$ openssl s_client -connect api.example.com:443 -showcerts
CONNECTED(00000003)
depth=0 CN = api.example.com
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = api.example.com
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
 0 s:CN = api.example.com
   i:CN = R3,O=Let's Encrypt,C=US
# Only server cert, missing intermediate

Certificate file contains only server certificate:

bash
$ openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/api.example.com.crt | openssl pkcs7 -print_certs -noout
subject=CN = api.example.com
issuer=CN = R3,O=Let's Encrypt,C=US
# Only one certificate in file - should have 2-3

Nginx/Apache error logs after renewal:

``` # Nginx 2026/04/11 14:39:15 [error] 1234#0: *567 SSL_do_handshake() failed (SSL: error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed) while SSL handshaking

# Apache [Sun Apr 11 14:39:15.123456 2026] [ssl:error] [pid 1234] AH02561: api.example.com:443: client sent a TLSv1.2 request but could not complete handshake ```

Browser shows certificate error:

``` Your connection is not private NET::ERR_CERT_AUTHORITY_INVALID

Subject: api.example.com Issuer: R3 # Missing: R3 -> ISRG Root X1 -> DST Root CA X3 chain ```

Common Causes

1. Overwriting Chain File Instead of Appending

The renewal task writes only the new certificate:

yaml
- name: Install new certificate
  copy:
    content: "{{ acme_result.cert }}"
    dest: /etc/ssl/certs/api.example.com.crt
  # Missing: {{ acme_result.chain }} should be appended

2. Incorrect Certificate Concatenation Order

Certificates must be ordered leaf-to-root, but are sometimes reversed:

```bash # Wrong order (root first) -----BEGIN CERTIFICATE----- [Root CA certificate] -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- [Intermediate CA] -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- [Server certificate] -----END CERTIFICATE-----

# Correct order (leaf first) -----BEGIN CERTIFICATE----- [Server certificate] -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- [Intermediate CA] -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- [Root CA] # Optional - usually trusted by client -----END CERTIFICATE----- ```

3. CA Changed Intermediate Certificates

Let's Encrypt and other CAs rotate intermediate certificates:

```bash # Old chain used R3 intermediate issuer=CN = R3,O=Let's Encrypt,C=US

# New chain uses R4 intermediate issuer=CN = R4,O=Let's Encrypt,C=US # But old intermediate still in chain file ```

4. Separate Chain File Not Configured

The web server is configured to use the cert file but not the chain file:

nginx
ssl_certificate /etc/ssl/certs/api.example.com.crt;
ssl_certificate_key /etc/ssl/private/api.example.com.key;
# Missing: ssl_trusted_certificate /etc/ssl/certs/api.example.com.chain.crt;

5. ACME Client Only Downloads Leaf Certificate

Some ACME clients separate the certificate and chain:

yaml
- name: Get certificate from ACME
  acme_certificate:
    account_key: /etc/ssl/acme-account.key
    csr: /etc/ssl/certs/api.example.com.csr
    dest: /etc/ssl/certs/api.example.com.crt
    # Only downloads leaf cert, not chain
    # Need: chain_dest: /etc/ssl/certs/api.example.com.chain.crt

Step-by-Step Fix

Step 1: Diagnose the Chain Issue

Check current certificate chain:

```bash # View certificates in chain file openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/api.example.com.crt | \ openssl pkcs7 -print_certs -noout

# Check online SSL configuration openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>/dev/null | \ awk '/BEGIN CERT/,/END CERT/' | csplit -s -f cert- - '/-----BEGIN CERTIFICATE-----/' '{*}' for cert in cert-*; do [ -s "$cert" ] && openssl x509 -noout -subject -issuer -in "$cert" done

# Verify chain openssl verify -CAfile /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/api.example.com.crt ```

Check what certificates the ACME response contains:

yaml
- name: Debug ACME certificate response
  debug:
    msg:
      - "Cert length: {{ acme_result.cert | length }}"
      - "Chain length: {{ acme_result.chain | default('') | length }}"
      - "Fullchain length: {{ acme_result.fullchain | default('') | length }}"

Step 2: Fix Certificate Chain Assembly

Update the certificate renewal task to properly assemble the chain:

```yaml # renew_certificate.yml - name: Renew TLS certificate with complete chain hosts: web_servers vars: domain: api.example.com cert_dir: /etc/ssl/certs key_dir: /etc/ssl/private

tasks: - name: Request certificate from ACME community.crypto.acme_certificate: account_key_src: "{{ key_dir }}/acme-account.key" csr: "{{ cert_dir }}/{{ domain }}.csr" cert: "{{ cert_dir }}/{{ domain }}.crt" chain: "{{ cert_dir }}/{{ domain }}-chain.crt" fullchain: "{{ cert_dir }}/{{ domain }}-fullchain.crt" challenge: http-01 acme_directory: https://acme-v02.api.letsencrypt.org/directory acme_version: 2 terms_agreed: yes account_email: admin@example.com register: cert_result

  • name: Verify fullchain contains all certificates
  • shell: |
  • grep -c "BEGIN CERTIFICATE" {{ cert_dir }}/{{ domain }}-fullchain.crt
  • register: cert_count
  • changed_when: false
  • name: Ensure full chain is present
  • assert:
  • that:
  • - cert_count.stdout | int >= 2
  • fail_msg: "Certificate chain incomplete - expected at least 2 certificates, found {{ cert_count.stdout }}"
  • success_msg: "Certificate chain contains {{ cert_count.stdout }} certificates"
  • name: Display certificate chain details
  • shell: |
  • openssl crl2pkcs7 -nocrl -certfile {{ cert_dir }}/{{ domain }}-fullchain.crt | \
  • openssl pkcs7 -print_certs -noout
  • register: chain_details
  • changed_when: false
  • name: Show chain subjects
  • debug:
  • msg: "{{ chain_details.stdout_lines }}"
  • `

For manual chain assembly:

```yaml - name: Assemble certificate chain properly hosts: web_servers vars: domain: api.example.com cert_dir: /etc/ssl/certs

tasks: - name: Download intermediate certificates get_url: url: "{{ item.url }}" dest: "{{ cert_dir }}/{{ item.name }}" loop: - name: letsencrypt-r3.crt url: https://letsencrypt.org/certs/lets-encrypt-r3.pem - name: letsencrypt-isrgrootx1.crt url: https://letsencrypt.org/certs/isrgrootx1.pem

  • name: Assemble full certificate chain
  • shell: |
  • cat {{ cert_dir }}/{{ domain }}.crt \
  • {{ cert_dir }}/letsencrypt-r3.crt \
  • > {{ cert_dir }}/{{ domain }}-fullchain.crt
  • args:
  • creates: "{{ cert_dir }}/{{ domain }}-fullchain.crt"
  • name: Verify chain integrity
  • command: >
  • openssl verify
  • -CAfile {{ cert_dir }}/letsencrypt-isrgrootx1.crt
  • -untrusted {{ cert_dir }}/letsencrypt-r3.crt
  • {{ cert_dir }}/{{ domain }}.crt
  • register: verify_result
  • changed_when: false
  • name: Report verification result
  • debug:
  • msg: "{{ verify_result.stdout }}"
  • `

Step 3: Configure Web Server with Complete Chain

Update Nginx configuration:

```yaml - name: Configure Nginx with complete certificate chain hosts: web_servers vars: domain: api.example.com cert_dir: /etc/ssl/certs

tasks: - name: Update Nginx SSL configuration template: src: nginx-ssl.conf.j2 dest: /etc/nginx/conf.d/ssl.conf mode: '0644' validate: nginx -t -c %s notify: Reload Nginx

handlers: - name: Reload Nginx systemd: name: nginx state: reloaded ```

Nginx SSL template:

```nginx # templates/nginx-ssl.conf.j2 server { listen 443 ssl http2; server_name {{ domain }};

# Use fullchain for certificate (includes intermediates) ssl_certificate {{ cert_dir }}/{{ domain }}-fullchain.crt; ssl_certificate_key {{ key_dir }}/{{ domain }}.key;

# Optional: Separate chain file for OCSP stapling ssl_trusted_certificate {{ cert_dir }}/{{ domain }}-chain.crt;

# Modern SSL configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d;

# OCSP stapling ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8 8.8.4.4 valid=300s;

location / { proxy_pass http://localhost:8080; } } ```

For Apache:

```yaml - name: Configure Apache with complete certificate chain hosts: web_servers vars: domain: api.example.com cert_dir: /etc/ssl/certs

tasks: - name: Update Apache SSL configuration template: src: apache-ssl.conf.j2 dest: /etc/apache2/sites-available/{{ domain }}-ssl.conf mode: '0644' notify: Reload Apache

handlers: - name: Reload Apache systemd: name: apache2 state: reloaded ```

Apache SSL template:

```apache # templates/apache-ssl.conf.j2 <VirtualHost *:443> ServerName {{ domain }}

SSLEngine on # Server certificate SSLCertificateFile {{ cert_dir }}/{{ domain }}.crt # Private key SSLCertificateKeyFile {{ key_dir }}/{{ domain }}.key # Intermediate chain (CA certificates) SSLCertificateChainFile {{ cert_dir }}/{{ domain }}-chain.crt

# Modern SSL configuration SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256 SSLHonorCipherOrder off

DocumentRoot /var/www/html </VirtualHost> ```

Step 4: Add Chain Validation to Renewal Process

Include verification in the renewal playbook:

```yaml # renew_with_validation.yml - name: Renew certificate with chain validation hosts: web_servers vars: domain: api.example.com cert_dir: /etc/ssl/certs

tasks: - name: Renew certificate include_tasks: renew_certificate.yml

  • name: Verify certificate chain completeness
  • block:
  • - name: Check certificate file exists
  • stat:
  • path: "{{ cert_dir }}/{{ domain }}-fullchain.crt"
  • register: fullchain_stat
  • name: Count certificates in chain
  • shell: grep -c "BEGIN CERTIFICATE" {{ cert_dir }}/{{ domain }}-fullchain.crt
  • register: cert_count
  • changed_when: false
  • name: Verify against expected CA
  • shell: |
  • openssl verify \
  • -CAfile /etc/ssl/certs/ca-bundle.crt \
  • -untrusted <(grep -A1000 "BEGIN CERTIFICATE" {{ cert_dir }}/{{ domain }}-fullchain.crt | tail -n +2) \
  • {{ cert_dir }}/{{ domain }}.crt
  • register: chain_verify
  • name: Test actual TLS handshake
  • command: openssl s_client -connect localhost:443 -servername {{ domain }}
  • register: tls_test
  • changed_when: false
  • failed_when: "'Verify return code: 0 (ok)' not in tls_test.stderr"

rescue: - name: Rollback to previous certificate copy: src: "{{ cert_dir }}/{{ domain }}-fullchain.crt.bak" dest: "{{ cert_dir }}/{{ domain }}-fullchain.crt" remote_src: yes

  • name: Alert on failure
  • debug:
  • msg: "Certificate renewal failed chain validation - rolled back to previous certificate"
  • name: Fail the playbook
  • fail:
  • msg: "Certificate chain validation failed"
  • name: Backup current certificate before renewal
  • copy:
  • src: "{{ cert_dir }}/{{ domain }}-fullchain.crt"
  • dest: "{{ cert_dir }}/{{ domain }}-fullchain.crt.bak"
  • remote_src: yes
  • `

Step 5: Create Certificate Monitoring Playbook

Monitor certificate chain health:

```yaml # monitor_certificates.yml - name: Monitor TLS certificate chain health hosts: web_servers vars: domains: - api.example.com - www.example.com warning_days: 14 critical_days: 7

tasks: - name: Check certificate chain for each domain block: - name: Get certificate details command: > openssl s_client -connect {{ item }}:443 -servername {{ item }} </dev/null 2>/dev/null | openssl x509 -noout -dates -issuer -subject register: cert_info changed_when: false loop: "{{ domains }}"

  • name: Parse certificate expiration
  • set_fact:
  • cert_expiry: "{{ cert_info.results | map(attribute='stdout') | map('regex_search', 'notAfter=(.+)', '\\1') | list }}"
  • name: Check chain completeness
  • shell: >
  • echo | openssl s_client -connect {{ item }}:443 -servername {{ item }} 2>/dev/null |
  • awk '/BEGIN CERT/,/END CERT/' | grep -c "BEGIN CERTIFICATE"
  • register: chain_length
  • changed_when: false
  • loop: "{{ domains }}"
  • name: Report certificate status
  • debug:
  • msg:
  • - "Domain: {{ item.item }}"
  • - "Expiration: {{ cert_info.results[loop_index].stdout | regex_search('notAfter=(.+)') }}"
  • - "Chain length: {{ item.stdout }} certificates"
  • - "Status: {{ 'OK' if item.stdout | int >= 2 else 'INCOMPLETE CHAIN' }}"
  • loop: "{{ chain_length.results }}"
  • loop_control:
  • index_var: loop_index
  • name: Verify chain externally
  • uri:
  • url: "https://www.ssllabs.com/ssltest/analyze.html?d={{ item }}"
  • method: GET
  • status_code: 200
  • loop: "{{ domains }}"
  • delegate_to: localhost
  • run_once: true
  • `

Verification

Test the certificate chain is complete:

```bash # Check certificate count in chain grep -c "BEGIN CERTIFICATE" /etc/ssl/certs/api.example.com-fullchain.crt # Should be 2 or more

# Verify chain with OpenSSL openssl verify -CAfile /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/api.example.com-fullchain.crt # Should return: OK

# Test external verification openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>&1 | grep "Verify return code" # Should return: Verify return code: 0 (ok)

# Use SSL Labs test curl -s "https://api.ssllabs.com/api/v3/analyze?host=api.example.com" | jq '.endpoints[].statusMessage' ```

Test with curl:

```bash # Test with verbose output curl -vI https://api.example.com 2>&1 | grep -E "SSL certificate verify|subject:|issuer:"

# Test with certificate verification curl --cacert /etc/ssl/certs/ca-bundle.crt https://api.example.com/api/health ```

  • [ansible-clock-skew-breaks-signed-request-validation](/articles/ansible-clock-skew-breaks-signed-request-validation) - Time-related certificate issues
  • [ansible-mutual-tls-client-certificate-still-uses-an-old-issuer](/articles/ansible-mutual-tls-client-certificate-still-uses-an-old-issuer) - Client certificate chain issues
  • [ansible-health-check-fails-because-endpoint-redirects](/articles/ansible-health-check-fails-because-endpoint-redirects) - Endpoint configuration issues
  • [WordPress troubleshooting: Ansible Artifact Download Uses an Old Mi](ansible-artifact-download-uses-an-old-mirror-after-proxy-change)
  • [WordPress troubleshooting: Ansible Audit Trail Misses Events Under ](ansible-audit-trail-misses-events-under-burst-load)
  • [WordPress troubleshooting: Ansible Background Worker Gets Stuck in ](ansible-background-worker-stuck-in-a-retry-loop)
  • [WordPress troubleshooting: Ansible Backup Completes but Restore Fai](ansible-backup-completes-but-restore-fails-checksum-validation)
  • [WordPress troubleshooting: Ansible Batch Importer Duplicates Rows A](ansible-batch-importer-duplicates-rows-after-a-retry)

<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "TechArticle", "headline": "WordPress troubleshooting: Ansible Certificate Chain Is Incomplete ", "description": "Learn how to fix Ansible Certificate Chain Is Incomplete After Renewal. Professional WordPress troubleshooting solutions with step-by-step guidance. WP error fix, WordPress optimization, WP security, WordPress performance.", "url": "https://www.fixwikihub.com/ansible-certificate-chain-is-incomplete-after-renewal", "publisher": { "@type": "Organization", "name": "FixWikiHub", "url": "https://www.fixwikihub.com" }, "author": { "@type": "Person", "name": "FixWikiHub Editorial Team" }, "datePublished": "2026-02-12T09:12:38.491Z", "dateModified": "2026-02-12T09:12:38.491Z" } </script>