How I manage SSL certificates for my homelab with Letsencrypt and Ansible
Posted on vr 30 mei 2025 in ansible
How I manage SSL certificates for my homelab with Letsencrypt and Ansible
I have a fairly sizable homelab, consisting of some Raspberry Pi 4s, some Intel Nucs, a Synology NAS with a VM running on it and a number of free VMs in Oracle cloud. All these machines run RHEL 9 or RHEL 10 and all of them are managed from an instance of Red Hat Ansible Automation Platform that runs on the VM on my NAS.
On most of these machines, I run podman containers behind caddy (which takes care of any SSL certificate management automatically). But for some services, I really needed an automated way of managing SSL certificates that didn't involve Caddy. An example for this is cockpit, which I use on some occasions. I hate those "your connection is not secure messages", so I needed real SSL certificates that my whole network would trust without the need of me having to load custom CA certificates in every single device.
I also use this method for securing my internal Postfix relay, and (in a slightly different way) for setting up certificates for containers running on my NAS.
So. Ansible to the rescue. It turns out, there is a surprisingly easy way to do this with Ansible. I found some code floating around the internet. To be honest, I forgot where I got it, it was probably a GitHub gist, but I really don't remember: I wrote this playbook months and months ago - I would love to attribute credit for this, but I simply can't :(
The point of the playbook is that it takes a list of certificates that should exist on a machine, and it makes sure those certificates exist on the target machine. Because this is for machines that are not connected to the internet, it's not possible to use the standard HTTP verification. Instead, it creates temporary DNS records to verify my ownership of the domain.
Let's break down how the playbook works. I'll link to the full playbook at the end.
Keep in mind that all tasks below are meant to be run as a playbook looping over a list of dictionaries that are structures as follows:
le_certificates:
- common_name: "mymachine.example.com"
basedir: "/etc/letsencrypt"
domain: ".example.com"
email: security-team@example.com
First, we make sure a directory exists to store the certificate. We check for the existence of a Letsencrypt account key and if that does not exist, we create it and copy it over to the client:
- name: Create directory to store certificate information
ansible.builtin.file:
path: "{{ item.basedir }}"
state: directory
mode: "0710"
owner: "{{ cert_directory_user }}"
group: "{{ cert_directory_group }}"
- name: Check if account private key exists
ansible.builtin.stat:
path: "{{ item.basedir }}/account_{{ item.common_name }}.key"
register: account_key
- name: Generate and copy over the acme account private key
when: not account_key.stat.exists | bool
block:
- name: Generate private account key for letsencrypt
community.crypto.openssl_privatekey:
path: /tmp/account_{{ item.common_name }}.key
type: RSA
delegate_to: localhost
become: false
when: not account_key.stat.exists | bool
- name: Copy over private account key to client
ansible.builtin.copy:
src: /tmp/account_{{ item.common_name }}.key
dest: "{{ item.basedir }}/account_{{ item.common_name }}.key"
mode: "0640"
owner: root
group: root
The next step is to check for the existence of a private key for the domain we are handling, and create it and copy it to the client if it doesn't exist:
- name: Check if certificate private key exists
ansible.builtin.stat:
path: "{{ item.basedir }}/{{ item.common_name }}.key"
register: cert_key
- name: Generate and copy over the acme cert private key
when: not cert_key.stat.exists | bool
block:
- name: Generate private acme key for letsencrypt
community.crypto.openssl_privatekey:
path: /tmp/{{ item.common_name }}.key
type: RSA
delegate_to: localhost
become: false
when: not cert_key.stat.exists | bool
- name: Copy over private acme key to client
ansible.builtin.copy:
src: /tmp/{{ item.common_name }}.key
dest: "{{ item.basedir }}/{{ item.common_name }}.key"
mode: "0640"
owner: root
group: root
Then, we create a certificate signing request (CSR) based on the private key, and copy that to the client:
- name: Generate and copy over the csr
block:
- name: Grab the private key from the host
ansible.builtin.slurp:
src: "{{ item.basedir }}/{{ item.common_name }}.key"
register: remote_cert_key
- name: Generate the csr
community.crypto.openssl_csr:
path: /tmp/{{ item.common_name }}.csr
privatekey_content: "{{ remote_cert_key['content'] | b64decode }}"
common_name: "{{ item.common_name }}"
delegate_to: localhost
become: false
- name: Copy over csr to client
ansible.builtin.copy:
src: /tmp/{{ item.common_name }}.csr
dest: "{{ item.basedir }}/{{ item.common_name }}.csr"
mode: "0640"
owner: root
group: root
Now the slightly more complicated stuff starts. This next task contacts the Letsencrypt API and requests a certificate. It specifies a dns-01
challenge, which means that Letsencrypt will respond with a challenge that we can validate our request through the creation of a special DNS record. All we need is in the response, which well store as cert_challenge
.
- name: Create a challenge using an account key file.
community.crypto.acme_certificate:
account_key_src: "{{ item.basedir }}/account_{{ item.common_name }}.key"
account_email: "{{ item.email }}"
src: "{{ item.basedir }}/{{ item.common_name }}.csr"
cert: "{{ item.basedir }}/{{ item.common_name }}.crt"
challenge: dns-01
acme_version: 2
acme_directory: "{{ acme_dir }}"
# Renew if the certificate is at least 30 days old
remaining_days: 60
terms_agreed: true
register: cert_challenge
Now, I'll be using DigitalOcean's API to create the temporary DNS records, but you can use whatever DNS service you want, as long as it's publicly available for Letsencrypt to query. The following block will only run if two things are true:
1. the cert_challenge
is changed
, which is only so if we need to renew the certificate. Letsencrypt certificates are valid for 90 days only. We specified remaining_days: 60
, so if we run this playbook 30 or more days after its previous run, cert_challenge
will be changed
and the certificate will be renewed.
2. item.common_name (which is a variable that holds the requested DNS record) is part of the challenge_data
structure in cert_challenge
. This is to verify we actually got the correct data from the Letsencrypt API, and not just some metadata change.
The block looks like this:
- name: Actual certificate creation
when: cert_challenge is changed and item.common_name in cert_challenge.challenge_data
block:
- name: Create DNS challenge record on DO
community.digitalocean.digital_ocean_domain_record:
state: present
oauth_token: "{{ do_api_token }}"
domain: "{{ item.domain[1:] }}"
type: TXT
ttl: 60
name: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].record | replace(item.domain, '') }}"
data: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].resource_value }}"
delegate_to: localhost
become: false
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
community.crypto.acme_certificate:
account_key_src: "{{ item.basedir }}/account_{{ item.common_name }}.key"
account_email: "{{ item.email }}"
src: "{{ item.basedir }}/{{ item.common_name }}.csr"
cert: "{{ item.basedir }}/{{ item.common_name }}.crt"
fullchain: "{{ item.basedir }}/{{ item.domain[1:] }}-fullchain.crt"
chain: "{{ item.basedir }}/{{ item.domain[1:] }}-intermediate.crt"
challenge: dns-01
acme_version: 2
acme_directory: "{{ acme_dir }}"
remaining_days: 60
terms_agreed: true
data: "{{ cert_challenge }}"
- name: Remove DNS challenge record on DO
community.digitalocean.digital_ocean_domain_record:
state: absent
oauth_token: "{{ do_api_token }}"
domain: "{{ item.domain[1:] }}"
type: TXT
name: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].record | replace(item.domain, '') }}"
data: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].resource_value }}"
delegate_to: localhost
become: false
You'll notice that the TTL for this record is intentionally very low, because we don't need it other than for validation of the challenge, and we'll remove it after vertification. If you do not use DigitalOcean as a DNS provider, the first task in the block above will look different, obviously.
The second task in the block reruns the acme_certificate
task, and this time we pass the contents of the cert_challenge
variable as the data
parameter. Upon successful validation, we can store retrieve the new certificate, full chain and intermediate chain to disk. Basically, at this point, we are done without having to use certbot :)
Of course, in the third task, we clean up the temporary DNS record again.
I have a slightly different playbook to manage certificates on my NAS, and some additional tasks that configure Postfix to use this certificate, too, but those are probably useful for me only.
TL;DR: it you want to create a (set of) certificate(s) for a (group of) machine(s), running this playbook from AAP every month makes that really easy.
The main playbook looks like this:
---
# file: letsencrypt.yml
- name: Configure letsencrypt certificates
hosts: rhel_machines
gather_facts: false
become: true
vars:
debug: false
acme_dir: https://acme-v02.api.letsencrypt.org/directory
pre_tasks:
- name: Gather facts subset
ansible.builtin.setup:
gather_subset:
- "!all"
- default_ipv4
- default_ipv6
tasks:
- name: Include letsencrypt tasks for each certificate
ansible.builtin.include_tasks: letsencrypt_tasks.yml
loop: "{{ le_certificates }}"
The letsencrypt_tasks.yml
file is all of the above tasks combined into a single playbook:
---
# file: letsencrypt_tasks.yml
- name: Create directory to store certificate information
ansible.builtin.file:
path: "{{ item.basedir }}"
state: directory
mode: "0710"
owner: "{{ cert_directory_user }}"
group: "{{ cert_directory_group }}"
- name: Check if account private key exists
ansible.builtin.stat:
path: "{{ item.basedir }}/account_{{ item.common_name }}.key"
register: account_key
- name: Generate and copy over the acme account private key
when: not account_key.stat.exists | bool
block:
- name: Generate private account key for letsencrypt
community.crypto.openssl_privatekey:
path: /tmp/account_{{ item.common_name }}.key
type: RSA
delegate_to: localhost
become: false
when: not account_key.stat.exists | bool
- name: Copy over private account key to client
ansible.builtin.copy:
src: /tmp/account_{{ item.common_name }}.key
dest: "{{ item.basedir }}/account_{{ item.common_name }}.key"
mode: "0640"
owner: root
group: root
- name: Check if certificate private key exists
ansible.builtin.stat:
path: "{{ item.basedir }}/{{ item.common_name }}.key"
register: cert_key
- name: Generate and copy over the acme cert private key
when: not cert_key.stat.exists | bool
block:
- name: Generate private acme key for letsencrypt
community.crypto.openssl_privatekey:
path: /tmp/{{ item.common_name }}.key
type: RSA
delegate_to: localhost
become: false
when: not cert_key.stat.exists | bool
- name: Copy over private acme key to client
ansible.builtin.copy:
src: /tmp/{{ item.common_name }}.key
dest: "{{ item.basedir }}/{{ item.common_name }}.key"
mode: "0640"
owner: root
group: root
- name: Generate and copy over the csr
block:
- name: Grab the private key from the host
ansible.builtin.slurp:
src: "{{ item.basedir }}/{{ item.common_name }}.key"
register: remote_cert_key
- name: Generate the csr
community.crypto.openssl_csr:
path: /tmp/{{ item.common_name }}.csr
privatekey_content: "{{ remote_cert_key['content'] | b64decode }}"
common_name: "{{ item.common_name }}"
delegate_to: localhost
become: false
- name: Copy over csr to client
ansible.builtin.copy:
src: /tmp/{{ item.common_name }}.csr
dest: "{{ item.basedir }}/{{ item.common_name }}.csr"
mode: "0640"
owner: root
group: root
- name: Create a challenge using an account key file.
community.crypto.acme_certificate:
account_key_src: "{{ item.basedir }}/account_{{ item.common_name }}.key"
account_email: "{{ item.email }}"
src: "{{ item.basedir }}/{{ item.common_name }}.csr"
cert: "{{ item.basedir }}/{{ item.common_name }}.crt"
challenge: dns-01
acme_version: 2
acme_directory: "{{ acme_dir }}"
# Renew if the certificate is at least 30 days old
remaining_days: 60
terms_agreed: true
register: cert_challenge
- name: Actual certificate creation
when: cert_challenge is changed and item.common_name in cert_challenge.challenge_data
block:
- name: Create DNS challenge record on DO
community.digitalocean.digital_ocean_domain_record:
state: present
oauth_token: "{{ do_api_token }}"
domain: "{{ item.domain[1:] }}"
type: TXT
ttl: 60
name: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].record | replace(item.domain, '') }}"
data: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].resource_value }}"
delegate_to: localhost
become: false
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
community.crypto.acme_certificate:
account_key_src: "{{ item.basedir }}/account_{{ item.common_name }}.key"
account_email: "{{ item.email }}"
src: "{{ item.basedir }}/{{ item.common_name }}.csr"
cert: "{{ item.basedir }}/{{ item.common_name }}.crt"
fullchain: "{{ item.basedir }}/{{ item.domain[1:] }}-fullchain.crt"
chain: "{{ item.basedir }}/{{ item.domain[1:] }}-intermediate.crt"
challenge: dns-01
acme_version: 2
acme_directory: "{{ acme_dir }}"
remaining_days: 60
terms_agreed: true
data: "{{ cert_challenge }}"
- name: Remove DNS challenge record on DO
community.digitalocean.digital_ocean_domain_record:
state: absent
oauth_token: "{{ do_api_token }}"
domain: "{{ item.domain[1:] }}"
type: TXT
name: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].record | replace(item.domain, '') }}"
data: "{{ cert_challenge.challenge_data[item.common_name]['dns-01'].resource_value }}"
delegate_to: localhost
become: false
And finally, as part of host_vars, for each of my hosts a letsencrypt.yml
file exists containing:
---
le_certificates:
- common_name: "myhost.example.com"
basedir: "/etc/letsencrypt"
domain: ".example.com"
email: security-team@example.com
To be fair, there could probably be a lot of optimization done in that playbook, and I can't remember why I did it with .example.com
(with the leading dot) and then use item.domain[1:]
in so many places. But, I'm a lazy IT person, and I'm not fixing what isn't inherently broken :)
Hope this helps!
M