Introduction

Let's Encrypt provides free, automated TLS certificates via the ACME protocol. Ansible automates the entire lifecycle: account creation, domain validation (HTTP-01 or DNS-01 challenges), certificate issuance, Nginx/Apache configuration, and automated renewal. No more manual certificate management.

Using community.crypto ACME Modules

---
- name: Issue Let's Encrypt certificate
  hosts: webservers
  become: true
  vars:
    acme_domain: www.example.com
    acme_email: admin@example.com
    acme_directory: https://acme-v02.api.letsencrypt.org/directory
    # Use staging for testing:
    # acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory
    cert_dir: /etc/letsencrypt/live/{{ acme_domain }}
  tasks:
    - name: Create certificate directories
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        mode: '0700'
      loop:
        - "{{ cert_dir }}"
        - /etc/letsencrypt/account

    - name: Generate account key
      community.crypto.openssl_privatekey:
        path: /etc/letsencrypt/account/account.key
        size: 4096
        mode: '0600'

    - name: Generate domain private key
      community.crypto.openssl_privatekey:
        path: "{{ cert_dir }}/privkey.pem"
        size: 4096
        mode: '0600'

    - name: Generate CSR
      community.crypto.openssl_csr:
        path: "{{ cert_dir }}/csr.pem"
        privatekey_path: "{{ cert_dir }}/privkey.pem"
        common_name: "{{ acme_domain }}"
        subject_alt_name:
          - "DNS:{{ acme_domain }}"
          - "DNS:example.com"

    - name: Create ACME challenge
      community.crypto.acme_certificate:
        account_key_src: /etc/letsencrypt/account/account.key
        account_email: "{{ acme_email }}"
        csr: "{{ cert_dir }}/csr.pem"
        dest: "{{ cert_dir }}/fullchain.pem"
        acme_directory: "{{ acme_directory }}"
        acme_version: 2
        terms_agreed: true
        challenge: http-01
      register: acme_challenge

    - name: Create challenge directory
      ansible.builtin.file:
        path: /var/www/html/.well-known/acme-challenge
        state: directory
        mode: '0755'

    - name: Deploy HTTP-01 challenge files
      ansible.builtin.copy:
        content: "{{ item.value['http-01']['resource_value'] }}"
        dest: "/var/www/html/{{ item.value['http-01']['resource'] }}"
        mode: '0644'
      loop: "{{ acme_challenge.challenge_data | dict2items }}"
      when: acme_challenge is changed

    - name: Validate challenge and retrieve certificate
      community.crypto.acme_certificate:
        account_key_src: /etc/letsencrypt/account/account.key
        account_email: "{{ acme_email }}"
        csr: "{{ cert_dir }}/csr.pem"
        dest: "{{ cert_dir }}/fullchain.pem"
        acme_directory: "{{ acme_directory }}"
        acme_version: 2
        terms_agreed: true
        challenge: http-01
        data: "{{ acme_challenge }}"
      when: acme_challenge is changed
      notify: reload nginx

DNS-01 Challenge (Wildcard Certificates)

- name: Issue wildcard certificate via DNS-01
  hosts: localhost
  vars:
    acme_domain: "*.example.com"
    acme_directory: https://acme-v02.api.letsencrypt.org/directory
  tasks:
    - name: Create ACME challenge
      community.crypto.acme_certificate:
        account_key_src: /etc/letsencrypt/account/account.key
        account_email: admin@example.com
        csr: /etc/letsencrypt/csr.pem
        dest: /etc/letsencrypt/fullchain.pem
        acme_directory: "{{ acme_directory }}"
        acme_version: 2
        terms_agreed: true
        challenge: dns-01
      register: dns_challenge

    - name: Create DNS TXT record (Cloudflare example)
      community.general.cloudflare_dns:
        zone: example.com
        type: TXT
        record: "{{ item.value['dns-01']['record'] }}"
        value: "{{ item.value['dns-01']['resource_value'] }}"
        api_token: "{{ vault_cloudflare_token }}"
      loop: "{{ dns_challenge.challenge_data | dict2items }}"
      when: dns_challenge is changed

    - name: Wait for DNS propagation
      ansible.builtin.pause:
        seconds: 60
      when: dns_challenge is changed

    - name: Validate and retrieve certificate
      community.crypto.acme_certificate:
        account_key_src: /etc/letsencrypt/account/account.key
        account_email: admin@example.com
        csr: /etc/letsencrypt/csr.pem
        dest: /etc/letsencrypt/fullchain.pem
        acme_directory: "{{ acme_directory }}"
        acme_version: 2
        terms_agreed: true
        challenge: dns-01
        data: "{{ dns_challenge }}"
      when: dns_challenge is changed

Using Certbot

---
- name: Install and run Certbot
  hosts: webservers
  become: true
  vars:
    certbot_domains:
      - example.com
      - www.example.com
    certbot_email: admin@example.com
  tasks:
    - name: Install Certbot
      ansible.builtin.package:
        name:
          - certbot
          - python3-certbot-nginx
        state: present

    - name: Obtain certificate with Nginx plugin
      ansible.builtin.command: >
        certbot certonly --nginx
        -d {{ certbot_domains | join(' -d ') }}
        --email {{ certbot_email }}
        --agree-tos
        --non-interactive
      args:
        creates: "/etc/letsencrypt/live/{{ certbot_domains[0] }}/fullchain.pem"
      notify: reload nginx

    - name: Set up auto-renewal cron
      ansible.builtin.cron:
        name: "certbot renewal"
        minute: "30"
        hour: "2"
        job: "certbot renew --quiet --post-hook 'systemctl reload nginx'"
        user: root

    - name: Test renewal
      ansible.builtin.command: certbot renew --dry-run
      register: renewal_test
      changed_when: false

Nginx SSL Configuration

- name: Deploy Nginx SSL config
  ansible.builtin.template:
    src: nginx-ssl.conf.j2
    dest: /etc/nginx/conf.d/{{ acme_domain }}.conf
    mode: '0644'
  notify: reload nginx
# templates/nginx-ssl.conf.j2
server {
    listen 80;
    server_name {{ acme_domain }};
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name {{ acme_domain }};

    ssl_certificate /etc/letsencrypt/live/{{ acme_domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ acme_domain }}/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    # HSTS
    add_header Strict-Transport-Security "max-age=63072000" always;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;

    root /var/www/{{ acme_domain }};
    index index.html;
}

Certificate Monitoring

- name: Check certificate expiry
  community.crypto.x509_certificate_info:
    path: "/etc/letsencrypt/live/{{ item }}/fullchain.pem"
  loop: "{{ certbot_domains }}"
  register: cert_info

- name: Alert on expiring certificates
  ansible.builtin.debug:
    msg: "⚠️ Certificate for {{ item.item }} expires {{ item.not_after }}"
  loop: "{{ cert_info.results }}"
  when: item.not_after is defined

Troubleshooting

Rate Limits

Use the staging server for testing:

acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory

Challenge Validation Failing

- name: Test HTTP challenge reachability
  ansible.builtin.uri:
    url: "http://{{ acme_domain }}/.well-known/acme-challenge/test"
    status_code: [200, 404]
  delegate_to: localhost

Conclusion

Ansible automates the full Let's Encrypt lifecycle — account creation, challenge validation, certificate issuance, web server configuration, and renewal scheduling. Use community.crypto.acme_certificate for full control or Certbot for simplicity. DNS-01 challenges enable wildcard certificates. Set up cron-based renewal and certificate expiry monitoring to never have an expired certificate again.