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