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
Related Articles
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.